From ee992ff0822f44d1c308822f116cb9d26f9a3386 Mon Sep 17 00:00:00 2001
From: Wes
Date: Mon, 17 Aug 2026 21:24:33 -0600
Subject: [PATCH 01/27] fix(desktop): restore release agent mentions (#6182)
## Summary
- preserve OSS relay-agent mentions under shared channel and agent
policy
- restrict owner-only release builds to relay agents with
cryptographically verified ownership matching the current user
- remove the remote policy replay loop that repeatedly rebuilt the relay
directory, while retaining focused polling and send-time revalidation
- query relay profiles and managed policies by exact author coordinates
to prevent noisy events from crowding out valid agents
## Diagnosis
The packaged Block release compiles
`BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY`, while ordinary OSS/dev
builds do not. Relay-discovered agents were filtered as if all remote
agents were outside that owner-only boundary, so a same-owner agent
running on another machine disappeared in the release even though the
OSS path could look healthy.
The fix uses the NIP-OA-authenticated owner from the relay directory as
the cross-machine proof. Internal builds admit only verified same-owner
agents and fail closed for missing, mismatched, stale/revoked, or
unavailable ownership evidence. OSS builds retain shared channel/policy
behavior.
## Validation
- desktop focused unit coverage: 39 tests passed
- desktop typecheck and focused static checks passed
- focused Tauri Rust policy/directory tests passed
- production-style E2E build succeeded
- targeted Playwright mention scenarios passed:
- owner-only release hides other-owned relay agent
- owner-only release shows verified same-owner relay agent
- OSS build shows shared `anyone` agent
- repository pre-push hook passed on
`4d40b6e5bb032f2c0755127172c50dee213f65a3`:
- branch skew
- desktop check and typecheck
- desktop tests
- Rust tests
- Tauri checks
- mobile tests
---------
Signed-off-by: Wes
Co-authored-by: Carl
---
.../agent_discovery/relay_directory.rs | 48 +++++-
.../src/commands/agent_models_tests.rs | 17 ++
.../src/commands/agent_models_update.rs | 9 +
.../src/commands/personas/inbound.rs | 1 +
.../personas/inbound/inbound_tests.rs | 6 +-
.../src-tauri/src/managed_agents/runtime.rs | 2 +
.../src/managed_agents/runtime/tests.rs | 4 +-
.../src/managed_agents/spawn_snapshot.rs | 26 +--
.../managed_agents/spawn_snapshot/tests.rs | 99 ++++++++++-
.../src-tauri/src/migration/backfill_tests.rs | 4 +
desktop/src/features/agents/AGENTS.md | 12 ++
.../lib/agentAutocompleteEligibility.test.mjs | 18 ++
.../lib/agentAutocompleteEligibility.ts | 18 +-
.../agents/lib/useAgentsDataRefresh.test.mjs | 106 ++----------
.../agents/lib/useAgentsDataRefresh.ts | 161 ++----------------
.../agents/lib/usePersonaSync.test.mjs | 52 +++++-
.../src/features/agents/lib/usePersonaSync.ts | 45 ++++-
desktop/src/testing/e2eBridge.ts | 3 +
desktop/tests/e2e/mentions.spec.ts | 30 ++++
19 files changed, 400 insertions(+), 261 deletions(-)
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 b97cefc0ee8..a4da9c475d9 100644
--- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
@@ -77,10 +77,22 @@ async fn query_all_relay_pages(
}
}
+fn owner_only_relay_directory() -> bool {
+ crate::managed_agents::owner_only_access_build()
+}
+
+fn retain_verified_owner(
+ verified_owners: &mut std::collections::HashMap,
+ required_owner: &str,
+) {
+ verified_owners.retain(|_, owner| owner.eq_ignore_ascii_case(required_owner));
+}
+
pub(crate) async fn list_relay_agents_for_state(
state: &AppState,
) -> Result, String> {
let viewer_pubkey = current_user_pubkey(state)?;
+ let owner_only = owner_only_relay_directory();
let relay_pubkey = identity_archive::fetch_relay_self(state)
.await?
.ok_or_else(|| "relay agent membership authority is unavailable".to_string())?;
@@ -128,7 +140,14 @@ pub(crate) async fn list_relay_agents_for_state(
// query. Each exact `(owner, d=agent)` filter returns at most one current
// replaceable event, so forged 30177 coordinates cannot amplify or crowd
// the authentic policy out of a bounded result page.
- let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events);
+ let mut verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events);
+ // The internal capability narrows the remote directory to cryptographically
+ // verified agents owned by the active user. Same-owner siblings remain
+ // mentionable because they are inside the harness's owner-only boundary;
+ // all cross-owner coordinates are discarded before policy lookup.
+ if owner_only {
+ 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) {
@@ -144,6 +163,14 @@ pub(crate) async fn list_relay_agents_for_state(
&managed_agent_events,
&profile_events,
);
+ if owner_only {
+ agents.retain(|agent| {
+ agent
+ .owner_pubkey
+ .as_deref()
+ .is_some_and(|owner| owner.eq_ignore_ascii_case(&viewer_pubkey))
+ });
+ }
agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey));
for agent in &mut agents {
agent.channel_ids = member_agent_channel_ids
@@ -163,6 +190,25 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result bool {
+ // Stored policy remains portable across OSS and owner-only builds, but a
+ // marked build always projects both states to the same owner-only runtime
+ // gate. Do not restart a fleet merely because relay state differs in bytes
+ // that this build cannot execute.
+ if enforced_owner_only {
+ return false;
+ }
prospective_mode != current_mode
|| (prospective_mode == crate::managed_agents::RespondTo::Allowlist
&& prospective_allowlist != current_allowlist)
@@ -169,6 +177,7 @@ pub async fn update_managed_agent(
&record.respond_to_allowlist,
prospective_mode,
&prospective_allowlist,
+ crate::managed_agents::owner_only_access_build(),
);
ensure_access_policy_change_supported(record, access_policy_changed)?;
diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs
index 42f720915a3..c322e6cb6e8 100644
--- a/desktop/src-tauri/src/commands/personas/inbound.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound.rs
@@ -585,6 +585,7 @@ fn apply_inbound_managed_agent(
&previous_allowlist,
local.respond_to,
&local.respond_to_allowlist,
+ crate::managed_agents::owner_only_access_build(),
);
}
false
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 c7235bd034a..c0526222151 100644
--- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
@@ -265,7 +265,11 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() {
let mut agents = vec![local_agent()];
let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
- assert!(access_changed, "Anyone must trigger a runtime refresh");
+ assert_eq!(
+ access_changed,
+ !crate::managed_agents::owner_only_access_build(),
+ "only an effective access change may trigger a runtime refresh"
+ );
let a = &agents[0];
// Secrets / harness / runtime — every one preserved from the local record.
assert_eq!(
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index b1c342e9955..865d31da1ed 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -252,6 +252,7 @@ pub fn build_managed_agent_summary(
&teams,
&key.relay_url,
global_config,
+ super::owner_only_access_build(),
);
(runtime, current)
});
@@ -857,6 +858,7 @@ pub fn spawn_agent_child(
system_prompt: effective_prompt.as_deref(),
model: effective_model.as_deref(),
provider: effective_provider.as_deref(),
+ enforced_owner_only: super::owner_only_access_build(),
},
);
diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
index 762b0fe2a61..edb4fad422e 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -1239,7 +1239,6 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
use std::process::{Command, Stdio};
// Spawn a real child so ManagedAgentProcess's Child field is satisfied.
// `true` exits immediately with 0 — just a handle we need for type purposes.
- //
// Absolute `/usr/bin/true` on unix (present on both macOS and Linux):
// parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a
// bare `true` lookup during that window fails with NotFound (observed
@@ -1256,13 +1255,14 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
.expect("spawn true for placeholder");
let process = crate::managed_agents::ManagedAgentProcess {
child,
- log_path: std::path::PathBuf::new(),
+ log_path: Default::default(),
spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
&minimal_record(&"cc".repeat(32)),
&[],
&[],
"wss://relay.example",
&Default::default(),
+ false,
),
setup_mode: false,
adapter_availability: None,
diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
index ba2129c9841..357f5f1e26d 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
@@ -72,6 +72,9 @@ pub(crate) struct SpawnConfigInputs<'a> {
pub system_prompt: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
+ /// Compile-time distribution capability projected at this runtime boundary.
+ /// The stored record remains portable; only effective spawned access is stamped.
+ pub enforced_owner_only: bool,
}
/// The effective spawn configuration of one managed-agent process.
@@ -136,7 +139,10 @@ impl SpawnConfigSnapshot {
system_prompt,
model,
provider,
+ enforced_owner_only,
} = inputs;
+ let (respond_to, respond_to_allowlist) =
+ super::projected_access_with_policy(record, enforced_owner_only);
Self {
acp_command: record.acp_command.clone(),
command: descriptor.command.clone(),
@@ -155,16 +161,14 @@ impl SpawnConfigSnapshot {
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten(),
auth_tag: record.auth_tag.clone(),
- respond_to: record.respond_to.as_str().to_string(),
- respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then(
- || {
- // A list spawn would reject is captured raw: the stamped
- // snapshot comes from a successful spawn, so any invalid
- // edit correctly compares unequal.
- super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
- .unwrap_or_else(|_| record.respond_to_allowlist.clone())
- },
- ),
+ respond_to: respond_to.as_str().to_string(),
+ respond_to_allowlist: (respond_to == super::types::RespondTo::Allowlist).then(|| {
+ // A list spawn would reject is captured raw: the stamped
+ // snapshot comes from a successful spawn, so any invalid
+ // edit correctly compares unequal.
+ super::types::validate_respond_to_allowlist(&respond_to_allowlist)
+ .unwrap_or(respond_to_allowlist)
+ }),
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
// Hash the effective parallelism so over-cap edits that don't change
@@ -213,6 +217,7 @@ pub(crate) fn prospective_spawn_config_snapshot(
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
+ enforced_owner_only: bool,
) -> SpawnConfigSnapshot {
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
// start/restore paths run right before spawning, so this describes what a
@@ -262,6 +267,7 @@ pub(crate) fn prospective_spawn_config_snapshot(
system_prompt: prompt.as_deref(),
model: model.as_deref(),
provider: provider.as_deref(),
+ enforced_owner_only,
})
}
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 20e02871eba..89bba15cee4 100644
--- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
@@ -5,6 +5,25 @@ use std::collections::BTreeMap;
/// Canonical projection of a prospective snapshot — the exact value the drift
/// comparison reads, so these tests assert on drift itself rather than on a
/// proxy for it.
+fn snapshot_with_policy(
+ record: &ManagedAgentRecord,
+ personas: &[AgentDefinition],
+ teams: &[TeamRecord],
+ workspace_relay: &str,
+ global: &GlobalAgentConfig,
+ enforced_owner_only: bool,
+) -> serde_json::Value {
+ prospective_spawn_config_snapshot(
+ record,
+ personas,
+ teams,
+ workspace_relay,
+ global,
+ enforced_owner_only,
+ )
+ .canonical()
+}
+
fn snapshot(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
@@ -12,7 +31,7 @@ fn snapshot(
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> serde_json::Value {
- prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical()
+ snapshot_with_policy(record, personas, teams, workspace_relay, global, false)
}
fn record() -> ManagedAgentRecord {
@@ -225,6 +244,84 @@ fn stored_record_relay_does_not_affect_snapshot() {
);
}
+#[test]
+fn owner_only_mode_and_allowlist_edits_do_not_change_effective_snapshot() {
+ let mut before = record();
+ before.respond_to = RespondTo::Allowlist;
+ before.respond_to_allowlist = vec!["a".repeat(64)];
+
+ let mut mode_edited = before.clone();
+ mode_edited.respond_to = RespondTo::Anyone;
+
+ let mut allowlist_edited = before.clone();
+ allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)];
+
+ let effective_before = snapshot_with_policy(
+ &before,
+ &[],
+ &[],
+ "wss://ws.example",
+ &Default::default(),
+ true,
+ );
+ for (label, edited) in [
+ ("respond-to mode", mode_edited),
+ ("respond-to allowlist", allowlist_edited),
+ ] {
+ assert_eq!(
+ effective_before,
+ snapshot_with_policy(
+ &edited,
+ &[],
+ &[],
+ "wss://ws.example",
+ &Default::default(),
+ true,
+ ),
+ "portable {label} edit must not create restart drift when both spawns enforce owner-only",
+ );
+ }
+}
+
+#[test]
+fn oss_mode_and_allowlist_edits_change_effective_snapshot() {
+ let mut before = record();
+ before.respond_to = RespondTo::Allowlist;
+ before.respond_to_allowlist = vec!["a".repeat(64)];
+
+ let mut mode_edited = before.clone();
+ mode_edited.respond_to = RespondTo::Anyone;
+
+ let mut allowlist_edited = before.clone();
+ allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)];
+
+ let effective_before = snapshot_with_policy(
+ &before,
+ &[],
+ &[],
+ "wss://ws.example",
+ &Default::default(),
+ false,
+ );
+ for (label, edited) in [
+ ("respond-to mode", mode_edited),
+ ("respond-to allowlist", allowlist_edited),
+ ] {
+ assert_ne!(
+ effective_before,
+ snapshot_with_policy(
+ &edited,
+ &[],
+ &[],
+ "wss://ws.example",
+ &Default::default(),
+ false,
+ ),
+ "OSS spawn must retain restart drift for effective {label} edits",
+ );
+ }
+}
+
#[test]
fn respond_to_allowlist_edit_changes_snapshot() {
let rec = record();
diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs
index d277a2aa5fc..754a40769c1 100644
--- a/desktop/src-tauri/src/migration/backfill_tests.rs
+++ b/desktop/src-tauri/src/migration/backfill_tests.rs
@@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
+ false,
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
@@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
+ false,
);
assert_eq!(
@@ -187,6 +189,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
+ false,
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
@@ -203,6 +206,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
+ false,
);
assert_eq!(before.canonical(), after.canonical());
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 53a39824c4e..fa352284dd9 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -202,6 +202,18 @@ with a TypeScript lookup table or an id comparison in a component.
panel shell or return navigation, but must not filter or replace profile
content.
+12. **Owner-only builds discover only verified same-owner remote agents.**
+ The native `list_relay_agents` boundary authenticates ownership through the
+ agent's NIP-OA profile, then retains only agents owned by the active user
+ when the compiled owner-only capability is present. Keep this as the
+ authoritative backstop: internal builds must never admit cross-owner remote
+ agents, while same-owner agents on another machine remain inside the
+ documented owner-only trust boundary. OSS builds retain the complete
+ policy-filtered relay directory and send-time fail-closed mention
+ revalidation. Local `agents-data-changed` events refresh only local
+ persona/team/managed-agent caches; they must never invalidate the remote
+ relay directory.
+
## The tests that enforce this
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs
index d7a6e759635..c2171e9d7d6 100644
--- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs
+++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs
@@ -66,11 +66,13 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
assert.equal(
relayAgentIsSharedWithUser(
{
+ ownerPubkey: OTHER_OWNER_PUBKEY,
respondTo: "owner-only",
respondToAllowlist: [],
channelIds: ["general"],
},
sharedChannelIds,
+ CURRENT_PUBKEY,
),
false,
);
@@ -83,6 +85,22 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
);
});
+test("relayAgentIsSharedWithUser: accepts verified same-owner agents across machines", () => {
+ assert.equal(
+ relayAgentIsSharedWithUser(
+ {
+ ownerPubkey: CURRENT_PUBKEY.toUpperCase(),
+ respondTo: "owner-only",
+ respondToAllowlist: [],
+ channelIds: ["general"],
+ },
+ new Set(["general"]),
+ CURRENT_PUBKEY,
+ ),
+ true,
+ );
+});
+
test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => {
const sharedChannelIds = new Set(["general"]);
diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
index a4b235fa04c..516520e2ca3 100644
--- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
+++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
@@ -10,7 +10,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
}
export function relayAgentIsSharedWithUser(
- agent: Pick,
+ agent: Pick<
+ RelayAgent,
+ "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
+ >,
sharedChannelIds: ReadonlySet,
currentPubkey?: string | null,
) {
@@ -18,6 +21,14 @@ export function relayAgentIsSharedWithUser(
? normalizePubkey(currentPubkey)
: null;
+ if (
+ agent.respondTo === "owner-only" &&
+ normalizedCurrentPubkey &&
+ agent.ownerPubkey
+ ) {
+ return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
+ }
+
if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
return agent.respondToAllowlist
.map((pubkey) => normalizePubkey(pubkey))
@@ -31,7 +42,10 @@ export function relayAgentIsSharedWithUser(
}
export function relayAgentCanRespondInChannel(
- agent: Pick,
+ agent: Pick<
+ RelayAgent,
+ "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
+ >,
channelId: string,
currentPubkey?: string | null,
) {
diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs
index 7a3643a3088..a836f9c7dfc 100644
--- a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs
+++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs
@@ -1,101 +1,17 @@
import assert from "node:assert/strict";
-import test, { mock } from "node:test";
+import test from "node:test";
-import { relayClient } from "@/shared/api/relayClient";
-import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
-import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts";
+import { relayAgentsQueryKey } from "@/features/agents/hooks";
+import { LOCAL_AGENT_DATA_QUERY_KEYS } from "./useAgentsDataRefresh.ts";
-const coordinates = [
- { ownerPubkey: "owner-a", agentPubkey: "agent-a" },
- { ownerPubkey: "owner-b", agentPubkey: "agent-b" },
-];
+const serializedLocalKeys = LOCAL_AGENT_DATA_QUERY_KEYS.map((key) =>
+ JSON.stringify(key),
+);
-function event(pubkey, dTag) {
- return {
- id: "id",
- pubkey,
- created_at: 1,
- kind: KIND_MANAGED_AGENT,
- tags: dTag ? [["d", dTag]] : [],
- content: "{}",
- sig: "sig",
- };
-}
-
-test("remote managed policy refresh accepts only exact authenticated coordinates", async () => {
- let onEvent;
- let filter;
- let unsubscribeCalls = 0;
- mock.method(relayClient, "subscribeLive", (nextFilter, listener) => {
- filter = nextFilter;
- onEvent = listener;
- return Promise.resolve(() => {
- unsubscribeCalls += 1;
- return Promise.resolve();
- });
- });
-
- let refreshes = 0;
- const stop = startRelayAgentPolicyRefresh(coordinates, () => {
- refreshes += 1;
- });
- await new Promise((resolve) => setImmediate(resolve));
-
- assert.deepEqual(filter, {
- kinds: [KIND_MANAGED_AGENT],
- authors: ["owner-a", "owner-b"],
- "#d": ["agent-a", "agent-b"],
- limit: 0,
- });
- onEvent(event("owner-a", "agent-a"));
- assert.equal(refreshes, 1);
-
- for (const irrelevant of [
- event("owner-x", "agent-a"),
- event("owner-a", "agent-x"),
- event("owner-a", "agent-b"), // authors×d cross-product
- event("owner-a", null),
- ]) {
- onEvent(irrelevant);
- }
- assert.equal(refreshes, 1, "irrelevant coordinates must not refresh");
-
- stop();
- assert.equal(unsubscribeCalls, 1);
- mock.reset();
-});
-
-test("stopping before subscription readiness still closes the live query", async () => {
- let resolveSubscription;
- let unsubscribeCalls = 0;
- mock.method(
- relayClient,
- "subscribeLive",
- () =>
- new Promise((resolve) => {
- resolveSubscription = resolve;
- }),
+test("local agent refresh never invalidates the relay directory", () => {
+ assert.equal(
+ serializedLocalKeys.includes(JSON.stringify(relayAgentsQueryKey)),
+ false,
+ "local reconciliation must not trigger a relay-wide directory rebuild",
);
-
- const stop = startRelayAgentPolicyRefresh(coordinates, () => {});
- stop();
- resolveSubscription(() => {
- unsubscribeCalls += 1;
- return Promise.resolve();
- });
- await new Promise((resolve) => setImmediate(resolve));
-
- assert.equal(unsubscribeCalls, 1);
- mock.reset();
-});
-
-test("no authenticated coordinates creates no global subscription", () => {
- let subscriptions = 0;
- mock.method(relayClient, "subscribeLive", () => {
- subscriptions += 1;
- return Promise.resolve(() => Promise.resolve());
- });
- startRelayAgentPolicyRefresh([], () => {})();
- assert.equal(subscriptions, 0);
- mock.reset();
});
diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts
index 0349618d114..b086f12a9c4 100644
--- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts
+++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts
@@ -2,94 +2,25 @@ import { listen } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
-import { relayClient } from "@/shared/api/relayClient";
-import type { RelayAgent, RelayEvent } from "@/shared/api/types";
-import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
import {
managedAgentsQueryKey,
personasQueryKey,
- relayAgentsQueryKey,
teamsQueryKey,
} from "@/features/agents/hooks";
import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks";
-const COALESCE_MS = 200;
-export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000;
-
-export type RelayAgentPolicyCoordinate = {
- agentPubkey: string;
- ownerPubkey: string;
-};
-
-function eventDTag(event: RelayEvent): string | null {
- return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
-}
-
-/**
- * Subscribe only to authenticated managed-agent coordinates already returned by
- * the relay directory. The callback repeats the exact owner+d check because a
- * combined Nostr filter admits the authors×d cross-product.
- */
-export function startRelayAgentPolicyRefresh(
- coordinates: RelayAgentPolicyCoordinate[],
- onChange: () => void,
- onError: (error: unknown) => void = (error) => {
- console.warn("Couldn’t subscribe to managed agent policy updates", error);
- },
-): () => void {
- if (coordinates.length === 0) return () => {};
-
- const allowed = new Set(
- coordinates.map(
- ({ ownerPubkey, agentPubkey }) =>
- `${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`,
- ),
- );
- const authors = [
- ...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)),
- ];
- const agentPubkeys = [
- ...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)),
- ];
- let disposed = false;
- let unsubscribe: (() => Promise) | null = null;
- void relayClient
- .subscribeLive(
- {
- kinds: [KIND_MANAGED_AGENT],
- authors,
- "#d": agentPubkeys,
- limit: 0,
- },
- (event) => {
- const dTag = eventDTag(event);
- if (
- dTag &&
- allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`)
- ) {
- onChange();
- }
- },
- )
- .then((nextUnsubscribe) => {
- if (disposed) void nextUnsubscribe();
- else unsubscribe = nextUnsubscribe;
- })
- .catch(onError);
-
- return () => {
- disposed = true;
- void unsubscribe?.();
- };
-}
+export const LOCAL_AGENT_DATA_QUERY_KEYS = [
+ personasQueryKey,
+ teamsQueryKey,
+ managedAgentsQueryKey,
+] as const;
-function relayPolicyCoordinates(agents: RelayAgent[] | undefined) {
- return (agents ?? []).flatMap((agent) =>
- agent.ownerPubkey
- ? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }]
- : [],
- );
-}
+// Trailing-coalesce local agent-store bursts into one cache refresh. The relay
+// directory is deliberately excluded: local persona/team/agent reconciliation
+// cannot change remote directory records, and rebuilding that directory is a
+// relay-wide operation. Remote data keeps its focused poll and is revalidated
+// directly before an agent mention is sent.
+const COALESCE_MS = 200;
export function useAgentsDataRefresh(): void {
const queryClient = useQueryClient();
@@ -107,80 +38,16 @@ export function useAgentsDataRefresh(): void {
const unlisten = listen("agents-data-changed", () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
- void queryClient.invalidateQueries({ queryKey: personasQueryKey });
- void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
- void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
- void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
+ for (const queryKey of LOCAL_AGENT_DATA_QUERY_KEYS) {
+ void queryClient.invalidateQueries({ queryKey });
+ }
}, COALESCE_MS);
});
- let policyStop = () => {};
- let policyTimer: ReturnType | undefined;
- let policyDirty = false;
- let policyRefreshInFlight = false;
- let policyDisposed = false;
- let coordinateKey = "";
-
- const refreshPolicyDirectory = () => {
- if (policyRefreshInFlight || policyTimer !== undefined) {
- policyDirty = true;
- return;
- }
- policyRefreshInFlight = true;
- void queryClient
- .invalidateQueries({ queryKey: relayAgentsQueryKey })
- .finally(() => {
- policyRefreshInFlight = false;
- if (policyDisposed) return;
- policyTimer = setTimeout(() => {
- policyTimer = undefined;
- if (policyDirty) {
- policyDirty = false;
- refreshPolicyDirectory();
- }
- }, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS);
- });
- };
-
- const resubscribePolicy = () => {
- const coordinates = relayPolicyCoordinates(
- queryClient.getQueryData(relayAgentsQueryKey),
- );
- const nextKey = coordinates
- .map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`)
- .sort()
- .join("|");
- if (nextKey === coordinateKey) return;
- coordinateKey = nextKey;
- policyStop();
- policyStop = startRelayAgentPolicyRefresh(
- coordinates,
- refreshPolicyDirectory,
- );
- };
- resubscribePolicy();
- const unsubscribeQueryCache = queryClient
- .getQueryCache()
- .subscribe((event) => {
- if (
- event.query.queryKey.length === relayAgentsQueryKey.length &&
- event.query.queryKey.every(
- (value: unknown, index: number) =>
- value === relayAgentsQueryKey[index],
- )
- ) {
- resubscribePolicy();
- }
- });
-
return () => {
- policyDisposed = true;
if (timer !== undefined) clearTimeout(timer);
- if (policyTimer !== undefined) clearTimeout(policyTimer);
void unlisten.then((fn) => fn());
void unlistenRuntime.then((fn) => fn());
- unsubscribeQueryCache();
- policyStop();
};
}, [queryClient]);
}
diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs
index a67b68cc7fd..cfc0d901c3a 100644
--- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs
+++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs
@@ -8,7 +8,10 @@ import {
KIND_PERSONA,
KIND_TEAM,
} from "@/shared/constants/kinds";
-import { startPersonaSync } from "./usePersonaSync.ts";
+import {
+ coalesceManagedAgentBackfill,
+ startPersonaSync,
+} from "./usePersonaSync.ts";
const EXPECTED_KINDS = [
KIND_PERSONA,
@@ -17,6 +20,53 @@ const EXPECTED_KINDS = [
KIND_DELETION,
];
+function event({
+ id,
+ kind = KIND_MANAGED_AGENT,
+ createdAt,
+ pubkey = "owner-pubkey",
+ dTag = "agent-pubkey",
+}) {
+ return {
+ id,
+ pubkey,
+ created_at: createdAt,
+ kind,
+ tags: dTag ? [["d", dTag]] : [],
+ content: "{}",
+ sig: "sig",
+ };
+}
+
+test("startup backfill keeps only the newest managed-agent head per coordinate", () => {
+ const persona = event({
+ id: "persona",
+ kind: KIND_PERSONA,
+ createdAt: 1,
+ dTag: "persona-id",
+ });
+ const otherAgent = event({
+ id: "other-agent",
+ createdAt: 2,
+ dTag: "other-agent",
+ });
+ const oldest = event({ id: "oldest", createdAt: 1 });
+ const sameSecondLoser = event({ id: "f", createdAt: 3 });
+ const newest = event({ id: "a", createdAt: 3 });
+
+ assert.deepEqual(
+ coalesceManagedAgentBackfill([
+ oldest,
+ persona,
+ newest,
+ otherAgent,
+ sameSecondLoser,
+ ]).map(({ id }) => id),
+ ["persona", "a", "other-agent"],
+ "NIP-33 uses newest created_at and lowest id on a tie",
+ );
+});
+
// Regression guard for the fresh-start backfill gap (F3): a device that comes
// online AFTER another published gets zero history from a live-only `limit: 0`
// subscription, because reconnect-replay's since-cursor is undefined until the
diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts
index 66ed679ad95..57d33089a9b 100644
--- a/desktop/src/features/agents/lib/usePersonaSync.ts
+++ b/desktop/src/features/agents/lib/usePersonaSync.ts
@@ -20,6 +20,48 @@ const PERSONA_SYNC_KINDS = [
KIND_DELETION,
];
+function eventDTag(event: RelayEvent): string | null {
+ return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
+}
+
+function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean {
+ return (
+ candidate.created_at > current.created_at ||
+ (candidate.created_at === current.created_at && candidate.id < current.id)
+ );
+}
+
+/**
+ * Keep only the NIP-33 head for each managed-agent coordinate in a startup
+ * backfill. Applying historical policy revisions one by one can stop and start
+ * the same runtime for every revision; the retained store only needs the final
+ * head. Other event kinds stay in relay order because persona/team projections
+ * do not trigger runtime policy transitions and deletion ordering is separate.
+ */
+export function coalesceManagedAgentBackfill(
+ events: readonly RelayEvent[],
+): RelayEvent[] {
+ const heads = new Map();
+
+ for (const event of events) {
+ if (event.kind !== KIND_MANAGED_AGENT) continue;
+ const dTag = eventDTag(event);
+ if (!dTag) continue;
+ const coordinate = `${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`;
+ const current = heads.get(coordinate);
+ if (!current || eventIsNewer(event, current)) heads.set(coordinate, event);
+ }
+
+ return events.filter((event) => {
+ if (event.kind !== KIND_MANAGED_AGENT) return true;
+ const dTag = eventDTag(event);
+ if (!dTag) return true;
+ return (
+ heads.get(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) === event
+ );
+ });
+}
+
// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`:
// one-shot backfill of existing heads + tombstones, then a live subscription.
// Returns a disposer that closes the live subscription. Extracted from the hook
@@ -56,7 +98,8 @@ export function startPersonaSync(
.fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 })
.then((events) => {
if (onCancelled()) return;
- for (const event of events) reconcile(event);
+ for (const event of coalesceManagedAgentBackfill(events))
+ reconcile(event);
})
.catch((error) => {
console.warn("[usePersonaSync] backfill failed:", error);
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 4215d183ac2..1239f9522a3 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -113,6 +113,7 @@ type MockManagedAgentRuntimeSeed = {
type MockRelayAgentSeed = {
pubkey: string;
+ ownerPubkey?: string | null;
name: string;
agentType?: string;
capabilities?: string[];
@@ -851,6 +852,7 @@ type RawSendChannelMessageResponse = {
type RawRelayAgent = {
pubkey: string;
+ owner_pubkey?: string | null;
name: string;
agent_type: string;
channels: string[];
@@ -2324,6 +2326,7 @@ function resetMockRelayAgents(config?: E2eConfig) {
});
mockRelayAgents.push({
pubkey: seed.pubkey,
+ owner_pubkey: seed.ownerPubkey ?? null,
name: seed.name,
agent_type: seed.agentType ?? "goose",
channels: channels.map((channel) => channel.name),
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 4efd6dd7a3f..5562920a349 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1454,6 +1454,36 @@ test("owner-only builds hide other-owned relay agents", async ({ page }) => {
await expect(autocomplete(page)).toHaveCount(0);
});
+test("owner-only builds show verified same-owner relay agents", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ ownerOnlyAccessBuild: true,
+ searchProfiles: [
+ {
+ pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
+ displayName: "quinn",
+ ownerPubkey: MOCK_VIEWER_PUBKEY,
+ isAgent: true,
+ },
+ ],
+ relayAgents: [
+ {
+ pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
+ ownerPubkey: MOCK_VIEWER_PUBKEY,
+ name: "quinn",
+ respondTo: "owner-only",
+ channelNames: ["general"],
+ },
+ ],
+ });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await page.getByTestId("message-input").fill("@quinn");
+
+ await expect(autocomplete(page).getByText("quinn")).toBeVisible();
+});
+
test("relay-only allowlisted agents stay hidden outside their channel", async ({
page,
}) => {
From 978e585e8df893fe55aded854de07996b9412678 Mon Sep 17 00:00:00 2001
From: Wes
Date: Mon, 17 Aug 2026 21:54:34 -0600
Subject: [PATCH 02/27] chore(release): release Buzz Desktop version 0.5.16
(#6191)
## Buzz Desktop release v0.5.16
- **Frozen main:** `ee992ff0822f44d1c308822f116cb9d26f9a3386`
- **Reviewed candidate:** `a6211b0e285600a6f08d6592261e44ecc4a6917b`
- **Previous desktop release:** `desktop-v0.5.15`
- **Proposed immutable tag:** `desktop-v0.5.16`
This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.
The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.
Signed-off-by: Wes
Co-authored-by: Release Automation
---
.release/desktop-candidate.json | 14 +++++++-------
CHANGELOG.md | 13 +++++++++++++
desktop/package.json | 2 +-
desktop/src-tauri/Cargo.lock | 2 +-
desktop/src-tauri/Cargo.toml | 2 +-
desktop/src-tauri/tauri.conf.json | 2 +-
6 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json
index e668efa7b2a..2cfec8d7813 100644
--- a/.release/desktop-candidate.json
+++ b/.release/desktop-candidate.json
@@ -1,10 +1,10 @@
{
"schema": 2,
- "version": "0.5.15",
- "base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2",
- "previous_tag": "desktop-v0.5.14",
- "previous_base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a",
- "previous_merge_sha": "82f7ed1532f50e0d28afca5580ed522f1c2ef1ca",
- "tag": "desktop-v0.5.15",
- "commit_count": 18
+ "version": "0.5.16",
+ "base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386",
+ "previous_tag": "desktop-v0.5.15",
+ "previous_base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2",
+ "previous_merge_sha": "c8c8eb58ad5336f21d77e7b02517cd4604a9a7ae",
+ "tag": "desktop-v0.5.16",
+ "commit_count": 2
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b701ec2581..3f81baf7730 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## v0.5.16
+
+### Desktop and shared changes
+
+- fix(desktop): restore release agent mentions ([#6182](https://github.com/block/buzz/pull/6182)) ([`ee992ff0822f44d1c308822f116cb9d26f9a3386`](https://github.com/block/buzz/commit/ee992ff0822f44d1c308822f116cb9d26f9a3386))
+- test(desktop): cover exact workflow batch limit ([#6168](https://github.com/block/buzz/pull/6168)) ([`f8692fa9b52ddcfeb4b95fb4862109983509f131`](https://github.com/block/buzz/commit/f8692fa9b52ddcfeb4b95fb4862109983509f131))
+
+### Other repository changes
+
+- None
+
+[Compare desktop-v0.5.15...desktop-v0.5.16](https://github.com/block/buzz/compare/desktop-v0.5.15...desktop-v0.5.16)
+
## v0.5.15
### Desktop and shared changes
diff --git a/desktop/package.json b/desktop/package.json
index 963fe643d63..29d7d448639 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
- "version": "0.5.15",
+ "version": "0.5.16",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 7d5a3f67dcb..8724145dfd5 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
-version = "0.5.15"
+version = "0.5.16"
dependencies = [
"anyhow",
"arboard",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 6d14b04cf4c..1fa31d12d35 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"]
[package]
name = "buzz-desktop"
-version = "0.5.15"
+version = "0.5.16"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index dd7ab08e06d..884cf0624f3 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz",
- "version": "0.5.15",
+ "version": "0.5.16",
"identifier": "xyz.block.buzz.app",
"build": {
"beforeDevCommand": {
From f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 06:47:53 -0600
Subject: [PATCH 03/27] =?UTF-8?q?fix(desktop):=20eliminate=20mounted-view?=
=?UTF-8?q?=20CPU=20burn=20=E2=80=94=20compositor-safe=20shimmer,=20observ?=
=?UTF-8?q?er=20append=20fast=20path,=20poll-tick=20disk=20reads=20(#6198)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Live 0.5.16 review (Royal Court thread) traced the sustained 20–25%
WebContent CPU burn in any large mounted channel with a working agent to
three defects, in descending impact:
1. **Shimmer animation forces per-frame style + compositing walks.**
`.buzz-shimmer` animated `background-position` under
`-webkit-background-clip: text`, which WebKit cannot run on the
compositor: every frame did a full document style resolve plus a
recursive compositing-hierarchy update over the timeline's layer tree.
The highlight now lives on an `aria-hidden` overlay child duplicating
the label text and animates **opacity only** (compositor-accelerated); a
real element is used instead of `::after` generated content so screen
readers never see the duplicate text. Visual: the moving sweep becomes a
gentle pulse. `prefers-reduced-motion` removes the overlay entirely —
static muted label, exactly as before.
2. **Observer journal whole-journal dedup + re-sort per append.** Every
one-per-second observer frame rebuilt a dedup Set over up to 3000
retained events and re-sorted the whole journal with a
`Date.parse`-per-comparison comparator. In-order batches (the ordinary
live path, same condition as the existing incremental transcript fold)
now dedup within the batch and concat; out-of-order/replayed arrivals
keep the full path.
3. **Redundant disk reads in the 5s agent-list poll.**
`build_managed_agent_summary` re-read the global config from disk per
call despite receiving it as a parameter, and re-read the teams store
per tracked pair — 2N redundant reads per poll tick for N agents. Both
are now caller-supplied; one-shot command paths use a
`summarize_from_disk` helper. Same stores, same `unwrap_or_default`
failure posture, read once.
## Evidence (mechanism attribution, live 0.5.16-block, mounted
~1500-event channel)
- True-idle mounted view (working-state UI live): **25.08% mean / 24.15%
median** WebContent CPU; post working-state decay: **6.33% / 3.50%**.
- Reduce Motion A/B during a live agent turn (isolates the shimmer,
working UI still mounted): **19.50% mean → 5.39% mean** (72% collapse).
Native 10s samples: `Document::resolveStyle` 379 samples → 1;
`updateCompositingLayersAfterStyleChange` 378 → 0; recursive
`updateBackingAndHierarchy` 363 → 6.
- The shimmer mechanism predates 0.5.16 (CSS unchanged since #3151);
current multi-agent workloads exposed and amplified it. The mention
regression itself was fixed separately in #6182.
## Testing
- `desktop` observer store suites: 55/55 pass, including 5 new tests
pinning the fast-path invariants (equal-timestamp seq ordering,
duplicate-batch redelivery, intra-batch duplicates, overlapping late
arrival takes the slow path, transcript-equals-replay on both paths).
- `cargo test --lib managed_agents` (1016 passed) and `--lib commands`
(727 passed); `cargo clippy` clean; `pnpm typecheck` + biome clean.
- Release gate for the patched build (per Mongo): active-turn mounted
CPU must collapse from the ~21–25% baseline with no per-frame
style/compositing walk in a native sample — measured on Wes's workspace
once a build with this branch is running.
Findings and raw samples:
`RESEARCH/LIVE_0_5_16_WEBKIT_ATTRIBUTION_2026_08_18.md`,
`RESEARCH/RENDERER_STEADY_STATE_LOOP_AUDIT_2026_08_18.md`,
`.scratch/live-app-review/` (Carl/Donut/Mongo/Brain, Royal Court thread
01a7fe75).
---
*Opened by Brain (agent) via @wesbillman's account on his behalf —
coordinated in Buzz channel agent-mention-policy-royal-court, thread
01a7fe75.*
---------
Signed-off-by: Wes
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
---
.../src-tauri/src/commands/agent_models.rs | 10 +-
.../src/commands/agent_models_update.rs | 11 +-
.../src-tauri/src/commands/agent_settings.rs | 23 +---
desktop/src-tauri/src/commands/agents.rs | 91 +++++++-------
.../src-tauri/src/managed_agents/runtime.rs | 12 +-
.../src/features/agents/observerRelayStore.ts | 36 ++++--
.../observerTranscriptRetention.test.mjs | 113 ++++++++++++++++++
.../src/shared/styles/globals/animations.css | 68 ++++++-----
desktop/src/shared/ui/Shimmer.tsx | 5 +
9 files changed, 236 insertions(+), 133 deletions(-)
diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs
index 85d9da4dfa7..cb809b6c04a 100644
--- a/desktop/src-tauri/src/commands/agent_models.rs
+++ b/desktop/src-tauri/src/commands/agent_models.rs
@@ -18,11 +18,11 @@ use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRoll
use crate::{
app_state::AppState,
managed_agents::{
- build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor,
- find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents,
- load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args,
- resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest,
- AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest,
+ current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut,
+ known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas,
+ managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command,
+ save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo,
+ AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest,
UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs
index 5f91622b44b..bb045b81a24 100644
--- a/desktop/src-tauri/src/commands/agent_models_update.rs
+++ b/desktop/src-tauri/src/commands/agent_models_update.rs
@@ -250,16 +250,7 @@ pub async fn update_managed_agent(
None
};
- let summary = {
- let personas = load_personas(&app).unwrap_or_default();
- build_managed_agent_summary(
- &app,
- record,
- &runtimes,
- &personas,
- &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
- )?
- };
+ let summary = { super::super::agents::summarize_from_disk(&app, record, &runtimes)? };
let rollback = name_changed
.then(|| AgentUpdateRollback::new(previous_record, record, access_policy_changed));
(
diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs
index 2317930c1ef..6135c671606 100644
--- a/desktop/src-tauri/src/commands/agent_settings.rs
+++ b/desktop/src-tauri/src/commands/agent_settings.rs
@@ -4,9 +4,8 @@ use tauri::{AppHandle, Manager, State};
use crate::{
app_state::AppState,
managed_agents::{
- build_managed_agent_summary, current_instance_id, find_managed_agent_mut,
- load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes,
- ManagedAgentSummary,
+ current_instance_id, find_managed_agent_mut, load_managed_agents, save_managed_agents,
+ sync_managed_agent_processes, ManagedAgentSummary,
},
util::now_iso,
};
@@ -56,14 +55,7 @@ pub async fn set_managed_agent_start_on_app_launch(
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
- let personas = load_personas(&app).unwrap_or_default();
- build_managed_agent_summary(
- &app,
- record,
- &runtimes,
- &personas,
- &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
- )
+ super::agents::summarize_from_disk(&app, record, &runtimes)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -107,14 +99,7 @@ pub async fn set_managed_agent_auto_restart(
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
- let personas = load_personas(&app).unwrap_or_default();
- build_managed_agent_summary(
- &app,
- record,
- &runtimes,
- &personas,
- &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
- )
+ super::agents::summarize_from_disk(&app, record, &runtimes)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index c4fc0cf2f61..d1667a72bb4 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -26,6 +26,28 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result {
Ok(keys.public_key().to_hex())
}
+/// Build a summary from fresh disk state (personas, teams, global config).
+/// For one-shot command paths only — the 5s list poll calls
+/// `build_managed_agent_summary` directly with stores loaded once per call,
+/// not once per record.
+pub(super) fn summarize_from_disk(
+ app: &AppHandle,
+ record: &ManagedAgentRecord,
+ runtimes: &std::collections::HashMap<
+ crate::managed_agents::ManagedAgentRuntimeKey,
+ crate::managed_agents::ManagedAgentPairRuntime,
+ >,
+) -> Result {
+ build_managed_agent_summary(
+ app,
+ record,
+ runtimes,
+ &load_personas(app).unwrap_or_default(),
+ &load_teams(app).unwrap_or_default(),
+ &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
+ )
+}
+
/// Retain a freshly authored managed-agent event in the local store, flagged
/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held
/// body after `save_managed_agents`, NEVER across an `.await`: it acquires
@@ -333,18 +355,11 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
- let personas = load_personas(app).unwrap_or_default();
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
- build_managed_agent_summary(
- app,
- record,
- &runtimes,
- &personas,
- &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
- )
+ summarize_from_disk(app, record, &runtimes)
}
pub(super) async fn start_local_agent_with_preflight(
@@ -436,6 +451,7 @@ pub(super) async fn start_local_agent_with_preflight(
record,
&runtimes,
&personas,
+ &load_teams(app).unwrap_or_default(),
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
)
}
@@ -474,14 +490,22 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Err(format!(
"agent {pubkey} has unsupported backend kind: {backend:?}"
@@ -1171,14 +1167,7 @@ pub async fn stop_managed_agent(
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
- let personas = load_personas(&app).unwrap_or_default();
- build_managed_agent_summary(
- &app,
- record,
- &runtimes,
- &personas,
- &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
- )
+ summarize_from_disk(&app, record, &runtimes)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 865d31da1ed..8b57b161b77 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -133,6 +133,7 @@ pub fn build_managed_agent_summary(
record: &ManagedAgentRecord,
runtimes: &HashMap,
personas: &[crate::managed_agents::types::AgentDefinition],
+ teams: &[crate::managed_agents::TeamRecord],
global_config: &crate::managed_agents::GlobalAgentConfig,
) -> Result {
use crate::managed_agents::BackendKind;
@@ -195,12 +196,10 @@ pub fn build_managed_agent_summary(
let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas);
- let global_for_summary =
- crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config(
record,
personas,
- &global_for_summary,
+ global_config,
);
let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg
{
@@ -242,14 +241,13 @@ pub fn build_managed_agent_summary(
// env layering below — the caller loads it once and passes it in, so
// list-style callers pay one disk read per call rather than one per record.
- // The prospective side is computed only for a tracked pair: it costs a
- // teams-store read, and an unstamped agent has nothing to compare against.
+ // The prospective side is computed only for a tracked pair: an unstamped
+ // agent has nothing to compare against.
let tracked_spawn = pair_key.as_ref().zip(pair_runtime).map(|(key, runtime)| {
- let teams = crate::managed_agents::load_teams(app).unwrap_or_default();
let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
record,
personas,
- &teams,
+ teams,
&key.relay_url,
global_config,
super::owner_only_access_build(),
diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts
index 7ae4d0bfc81..88e8bba7547 100644
--- a/desktop/src/features/agents/observerRelayStore.ts
+++ b/desktop/src/features/agents/observerRelayStore.ts
@@ -243,11 +243,26 @@ function appendAgentEvents(
: events;
if (admissible.length === 0) return null;
- const seen = new Set(
- current.map(
- (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`,
- ),
- );
+ // Ordinary live path: the harness publishes frames in order once per
+ // second, so the whole batch lands strictly after the retained tail. In
+ // that case no admissible event can collide with a retained one (the
+ // journal is sorted), so dedup only needs to look inside the batch and the
+ // merged journal is a plain concat — no Set over the full journal and no
+ // whole-journal re-sort (whose comparator Date.parses per comparison).
+ // Out-of-order or replayed arrivals take the full dedup + re-sort path.
+ const currentLast = current.at(-1);
+ const allAtEnd =
+ !currentLast ||
+ admissible.every((event) => isObserverEventAfter(event, currentLast));
+
+ const seen = allAtEnd
+ ? new Set()
+ : new Set(
+ current.map(
+ (event) =>
+ `${event.timestamp.length}:${event.timestamp}:${event.seq}`,
+ ),
+ );
const added: ObserverEvent[] = [];
for (const event of admissible) {
const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`;
@@ -258,7 +273,9 @@ function appendAgentEvents(
if (added.length === 0) return null;
const sortedAdded = added.sort(compareObserverEvents);
- const sorted = [...current, ...sortedAdded].sort(compareObserverEvents);
+ const sorted = allAtEnd
+ ? [...current, ...sortedAdded]
+ : [...current, ...sortedAdded].sort(compareObserverEvents);
const trimmed = sorted.length > MAX_OBSERVER_EVENTS;
const final = trimmed
? sorted.slice(sorted.length - OBSERVER_EVENTS_LOW_WATER)
@@ -276,14 +293,11 @@ function appendAgentEvents(
});
}
- // The common live path appends a sorted batch after the retained window. Fold
+ // The common live path appends a sorted batch after the retained window
+ // (the same `allAtEnd` that authorized the concat fast-path above). Fold
// that batch through the transcript state once without rebuilding history.
// Out-of-order arrivals and cap eviction rebuild from the final window so
// stateful tool/permission relationships remain correct.
- const currentLast = current.at(-1);
- const allAtEnd =
- !currentLast ||
- sortedAdded.every((event) => compareObserverEvents(event, currentLast) > 0);
if (allAtEnd && !trimmed) {
let transcriptState =
transcriptByAgent.get(key) ?? createEmptyTranscriptState();
diff --git a/desktop/src/features/agents/observerTranscriptRetention.test.mjs b/desktop/src/features/agents/observerTranscriptRetention.test.mjs
index 861940c1193..aa7ab427c92 100644
--- a/desktop/src/features/agents/observerTranscriptRetention.test.mjs
+++ b/desktop/src/features/agents/observerTranscriptRetention.test.mjs
@@ -303,3 +303,116 @@ describe("live observer journal retention — eviction floor (reconnect replay)"
);
});
});
+
+describe("live observer journal — in-order append fast path ordering/dedup", () => {
+ // The common live path (every batch strictly after the retained tail) skips
+ // the whole-journal dedup Set and re-sort. These pin the observable
+ // invariants that authorize that skip: identical ordering, dedup, and
+ // transcript against the general path.
+ beforeEach(() => {
+ resetAgentObserverStore();
+ });
+
+ /** An event with an explicit timestamp, for equal-timestamp tie-breaks. */
+ function makeEventAt(seq, timestampMs) {
+ return {
+ ...makeEvent(seq),
+ timestamp: new Date(timestampMs).toISOString(),
+ };
+ }
+
+ it("test_equal_timestamp_batch_orders_by_seq", () => {
+ // A one-second harness frame batches several events sharing a timestamp;
+ // the tie-break is seq. Deliver them out of seq order in one batch.
+ const t = 1_760_000_100_000;
+ syncAgentObserverEvents(AGENT_PUBKEY, [makeEventAt(1, t - 1000)]);
+ syncAgentObserverEvents(AGENT_PUBKEY, [
+ makeEventAt(4, t),
+ makeEventAt(2, t),
+ makeEventAt(3, t),
+ ]);
+ assert.deepEqual(
+ getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
+ [1, 2, 3, 4],
+ "equal-timestamp events are retained in seq order",
+ );
+ });
+
+ it("test_duplicate_batch_redelivery_is_ignored", () => {
+ // Relay redelivery of the newest batch: every event duplicates the tail,
+ // so nothing is admitted, nothing is notified.
+ const batch = [makeEvent(1), makeEvent(2), makeEvent(3)];
+ syncAgentObserverEvents(AGENT_PUBKEY, batch);
+ let notifications = 0;
+ const unsubscribe = subscribeAgentObserverStore(() => {
+ notifications += 1;
+ });
+ try {
+ syncAgentObserverEvents(AGENT_PUBKEY, batch);
+ } finally {
+ unsubscribe();
+ }
+ assert.deepEqual(
+ getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
+ [1, 2, 3],
+ "a redelivered batch adds nothing",
+ );
+ assert.equal(notifications, 0, "a pure-duplicate batch notifies no one");
+ });
+
+ it("test_batch_with_intra_batch_duplicate_admits_once", () => {
+ // A batch strictly after the tail still dedups within itself.
+ syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(1)]);
+ syncAgentObserverEvents(AGENT_PUBKEY, [
+ makeEvent(2),
+ makeEvent(3),
+ makeEvent(2),
+ ]);
+ assert.deepEqual(
+ getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
+ [1, 2, 3],
+ "an intra-batch duplicate is admitted exactly once",
+ );
+ });
+
+ it("test_late_arrival_overlapping_tail_takes_slow_path_and_dedups", () => {
+ // A replayed window straddling the tail: partly duplicate, partly new,
+ // partly older-than-tail. Not all-after, so the general path must dedup
+ // against the whole journal and re-sort.
+ syncAgentObserverEvents(AGENT_PUBKEY, [
+ makeEvent(1),
+ makeEvent(2),
+ makeEvent(4),
+ ]);
+ syncAgentObserverEvents(AGENT_PUBKEY, [
+ makeEvent(2),
+ makeEvent(3),
+ makeEvent(4),
+ makeEvent(5),
+ ]);
+ const events = getAgentObserverSnapshot(AGENT_PUBKEY).events;
+ assert.deepEqual(
+ events.map((event) => event.seq),
+ [1, 2, 3, 4, 5],
+ "overlapping late arrival dedups against the journal and sorts into place",
+ );
+ assert.deepEqual(
+ getAgentTranscript(AGENT_PUBKEY),
+ buildTranscript(events),
+ "the transcript equals a full replay after a mixed-path sequence",
+ );
+ });
+
+ it("test_fast_path_transcript_equals_full_replay", () => {
+ // Pure in-order streaming (fast path every time) must produce the same
+ // derived transcript as a replay of the retained window.
+ fillSequential(50);
+ const events = getAgentObserverSnapshot(AGENT_PUBKEY).events;
+ assert.equal(events.length, 50);
+ assert.deepEqual(
+ getAgentTranscript(AGENT_PUBKEY),
+ buildTranscript(events),
+ "in-order fast-path appends derive the same transcript as a replay",
+ );
+ });
+});
diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css
index f8ae456d350..5a0723cdaa0 100644
--- a/desktop/src/shared/styles/globals/animations.css
+++ b/desktop/src/shared/styles/globals/animations.css
@@ -194,7 +194,6 @@
.buzz-shimmer {
--buzz-shimmer-duration: 2600ms;
- --buzz-shimmer-band: 250%;
--buzz-shimmer-highlight: color-mix(
in srgb,
hsl(var(--background)) 60%,
@@ -202,49 +201,58 @@
);
--buzz-shimmer-spread: 2rem;
- animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite;
+ color: hsl(var(--muted-foreground));
+ display: inline-block;
+ position: relative;
+}
+
+/*
+ * The highlight lives on an aria-hidden overlay child that duplicates the
+ * label text and animates ONLY opacity. The previous implementation animated
+ * background-position under -webkit-background-clip: text, which WebKit
+ * cannot run on the compositor: every animation frame forced a full document
+ * style resolve + compositing-hierarchy walk, burning ~20% CPU at rest
+ * whenever a large timeline was mounted with a working agent. Opacity is
+ * compositor-accelerated: the layer paints once and the pulse runs outside
+ * the web process's main thread. A real element (not ::after generated
+ * content) is used so the duplicate text can be aria-hidden — pseudo-element
+ * text is inconsistently exposed to screen readers and cannot be hidden.
+ */
+.buzz-shimmer > .buzz-shimmer-overlay {
+ animation: buzz-shimmer var(--buzz-shimmer-duration) ease-in-out infinite;
background-clip: text;
- background-image:
- linear-gradient(
- 90deg,
- transparent calc(50% - var(--buzz-shimmer-spread)),
- var(--buzz-shimmer-highlight) 50%,
- transparent calc(50% + var(--buzz-shimmer-spread))
- ),
- linear-gradient(hsl(var(--muted-foreground)), hsl(var(--muted-foreground)));
- background-position:
- 100% 0,
- 0 0;
- background-repeat: no-repeat;
- background-size:
- var(--buzz-shimmer-band) 100%,
- 100% 100%;
+ background-image: linear-gradient(
+ 90deg,
+ transparent calc(50% - var(--buzz-shimmer-spread)),
+ var(--buzz-shimmer-highlight) 50%,
+ transparent calc(50% + var(--buzz-shimmer-spread))
+ );
color: transparent;
- display: inline-block;
+ inset: 0;
+ overflow: inherit;
+ padding: inherit;
+ position: absolute;
+ text-overflow: inherit;
+ white-space: inherit;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes buzz-shimmer {
- 0% {
- background-position:
- 100% 0,
- 0 0;
+ 0%,
+ 100% {
+ opacity: 0;
}
- 100% {
- background-position:
- 0 0,
- 0 0;
+ 50% {
+ opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
- .buzz-shimmer {
+ .buzz-shimmer > .buzz-shimmer-overlay {
animation: none;
- background: none;
- color: hsl(var(--muted-foreground));
- -webkit-text-fill-color: currentcolor;
+ display: none;
}
}
diff --git a/desktop/src/shared/ui/Shimmer.tsx b/desktop/src/shared/ui/Shimmer.tsx
index 0b26ed032f8..252c29dd5ef 100644
--- a/desktop/src/shared/ui/Shimmer.tsx
+++ b/desktop/src/shared/ui/Shimmer.tsx
@@ -16,6 +16,11 @@ export function Shimmer({ children, className }: ShimmerProps) {
}
>
{children}
+ {/* Visual-only highlight copy; the sibling text node above is the sole
+ accessible content. */}
+
+ {children}
+
);
}
From 6d45f98665004d314468d98e50084996f4046cdf Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 07:36:25 -0600
Subject: [PATCH 04/27] ci: make file-size policy a first-class gate (#6187)
## Summary
- make the differential file-size ratchet a first-class repository gate
- run the same unfiltered gate from pre-push, `just check`, and CI
- remove hidden file-size coupling from Desktop, Web, and Mobile lint
commands
- isolate ratchet Git subprocesses from hook-exported repository state
## Why
The Desktop ratchet grew to govern `desktop/src-tauri/crates/**`, but
the pre-push `desktop-check` command remained path-filtered to non-Tauri
files. That contract drift allowed a Tauri Rust file-size regression
through local validation.
The ratchet already computes its own merge-base diff, so duplicating
governed paths in Lefthook and CI adds drift risk without meaningful
runtime savings. One root gate owns the policy now.
## Testing
- `just file-size-check`
- oversized untracked probe under `desktop/src-tauri/crates/**` fails
with the 1,000-line ceiling
- file-size core tests with hook-style `GIT_DIR` / `GIT_WORK_TREE`
environment
- `lefthook dump` confirms the unfiltered pre-push command
- mandatory pre-push suite passed on
`3217003db10c84d8a0c5f636ec4c92936777a002`
- `git diff --check`
Signed-off-by: Wes
Co-authored-by: Carl
---
.github/workflows/ci.yml | 13 +++----------
AGENTS.md | 24 ++++++++++++------------
Justfile | 19 ++++++++++++++-----
desktop/package.json | 2 +-
lefthook.yml | 7 +++++++
scripts/check-file-sizes-core.mjs | 6 ++++++
scripts/check-file-sizes-core.test.mjs | 12 +++++++++++-
web/package.json | 2 +-
8 files changed, 55 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13d1424ea7d..a832c0a0aff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,7 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
@@ -50,8 +51,6 @@ jobs:
- 'scripts/normative-corpus.json'
- 'justfile'
desktop:
- - 'scripts/check-file-sizes-core.mjs'
- - 'scripts/check-file-sizes-core.test.mjs'
- 'scripts/model-capabilities.json'
- 'scripts/normative-corpus.json'
- 'desktop/**'
@@ -60,13 +59,9 @@ jobs:
desktop-rust:
- 'desktop/src-tauri/**'
web:
- - 'scripts/check-file-sizes-core.mjs'
- - 'scripts/check-file-sizes-core.test.mjs'
- 'web/**'
- 'pnpm-lock.yaml'
mobile:
- - 'scripts/check-file-sizes-core.mjs'
- - 'scripts/check-file-sizes-core.test.mjs'
- 'mobile/**'
- 'scripts/mobile-release.sh'
- 'scripts/mobile-worktree-overrides.sh'
@@ -92,8 +87,8 @@ jobs:
scripts/test-mobile-release-candidate-publisher.sh
- name: Mobile worktree identity contract
run: scripts/test-mobile-worktree-overrides.sh
- - name: File size ratchet unit tests
- run: node --test scripts/check-file-sizes-core.test.mjs
+ - name: File size policy
+ run: just file-size-check
rust-lint:
name: Rust Lint
@@ -896,8 +891,6 @@ jobs:
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
- - name: File size ratchet
- run: node mobile/scripts/check-file-sizes.mjs
- name: Format check
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
diff --git a/AGENTS.md b/AGENTS.md
index d66ecbdc748..3a79c4b38d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -120,14 +120,14 @@ Run `just test` for integration tests if you touched `buzz-relay`,
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
-commit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript
-typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop
-JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are
-CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run
-`just ci` for the full local gate. Run `just hooks` to
-re-install hooks after env changes. Before agents run Git or hooks, activate the
-repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook
-commands to compensate for an unconfigured shell `PATH`.
+commit. **Pre-push hooks** run the repository-wide differential file-size gate,
+clippy (workspace + Tauri), desktop TypeScript typechecking (`tsc --noEmit`),
+and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter)
+— no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix
+all formatting in one shot. Run `just ci` for the full local gate. Run `just
+hooks` to re-install hooks after env changes. Before agents run Git or hooks,
+activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not
+rewrite hook commands to compensate for an unconfigured shell `PATH`.
**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push.
@@ -566,10 +566,10 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
- **Keep widgets small and composable.** One public widget per file; push
private sub-widgets (`_Foo`) into sibling `part` files under a
`/` folder rather than growing the page file. Hard ceiling:
- **1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via
- `just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
- If the guard trips, **split the file — never bump the limit or add an
- override to slip under it.**
+ **1000 lines/file**, enforced across Desktop, Web, and Mobile by the
+ repository-level `just file-size-check` gate (`just check`, CI, and every
+ pre-push). If the guard trips, **split the file — never bump the limit or add
+ an override to slip under it.**
- Feature modules must not import from other feature modules — only from
`shared/`.
- Use `Grid` tokens for spacing, `Radii` for border radius.
diff --git a/Justfile b/Justfile
index 5b2ed88c952..ce8647cf77c 100644
--- a/Justfile
+++ b/Justfile
@@ -91,8 +91,17 @@ build:
build-release:
cargo build --workspace --release
-# Run repo lint and formatting checks
-check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check
+# Run repo lint, formatting, and repository policy checks
+check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check file-size-check
+
+# Run the repository-wide differential file-size ratchet and its policy tests.
+# The ratchet inspects only files changed from the merge base, so this stays
+# cheap enough to run unconditionally without duplicating path filters.
+file-size-check:
+ node --test scripts/check-file-sizes-core.test.mjs
+ node desktop/scripts/check-file-sizes.mjs
+ node web/scripts/check-file-sizes.mjs
+ node mobile/scripts/check-file-sizes.mjs
# Format all Rust code
fmt:
@@ -120,7 +129,7 @@ desktop-check:
# Fix desktop lint and format issues
desktop-fix:
- cd {{desktop_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
+ cd {{desktop_dir}} && pnpm exec biome check --write .
# Run desktop TS helper unit tests
desktop-test:
@@ -641,7 +650,7 @@ web-check:
# Fix web lint and format issues
web-fix:
- cd {{web_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes
+ cd {{web_dir}} && pnpm exec biome check --write .
# Run web TypeScript checks
web-typecheck:
@@ -673,7 +682,7 @@ mobile-fix:
# Run mobile lint and format checks
mobile-check:
- unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs
+ unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze
# Run mobile tests
mobile-test:
diff --git a/desktop/package.json b/desktop/package.json
index 29d7d448639..073a764a1ba 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -12,7 +12,7 @@
"check:px-text": "node ./scripts/check-px-text.mjs",
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
"lint": "biome lint .",
- "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation",
+ "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"",
"preview": "vite preview",
diff --git a/lefthook.yml b/lefthook.yml
index 5b992f19afe..d3c8bddb83e 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -6,6 +6,9 @@
# changes, though CI's Desktop Core job does. Those commands are pure TS
# (biome + tsc + node:test) with no Rust dependency, so the extra trigger
# would be spurious locally.
+# - The repository-wide `file-size-check` is deliberately unfiltered. Its own
+# merge-base diff is the path filter; duplicating its governed roots here is
+# the coverage drift this gate is intended to prevent.
# - Deletion-only surface changes do not trigger local hooks: lefthook 2.1.x
# drops deleted paths from push-file discovery (`extractFiles` existence
# check, repository.go). CI's dorny/paths-filter catches deletions.
@@ -51,6 +54,10 @@ pre-push:
commands:
branch-skew:
run: ./scripts/check-branch-skew.sh
+ file-size-check:
+ # The ratchet computes its own merge-base diff, so path filtering here
+ # 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"]
run: just test-unit
diff --git a/scripts/check-file-sizes-core.mjs b/scripts/check-file-sizes-core.mjs
index 1365424628d..6c3b8c4bb93 100644
--- a/scripts/check-file-sizes-core.mjs
+++ b/scripts/check-file-sizes-core.mjs
@@ -3,10 +3,16 @@ import { promises as fs } from "node:fs";
import path from "node:path";
function git(args, cwd, options = {}) {
+ // Git hooks export repository-local GIT_* variables. Child commands that
+ // intentionally target `cwd` must not be redirected back to the hook's repo.
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
+ );
return execFileSync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
+ env,
...options,
});
}
diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs
index 14dfe4b8daa..9b4b910404d 100644
--- a/scripts/check-file-sizes-core.test.mjs
+++ b/scripts/check-file-sizes-core.test.mjs
@@ -13,7 +13,17 @@ import {
} from "./check-file-sizes-core.mjs";
function git(repo, ...args) {
- return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim();
+ // These fixture repositories inherit both hook configuration and Git's
+ // repository-local environment when this test runs from pre-push. Isolate
+ // them completely so fixture commits cannot recurse into the real checkout.
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
+ );
+ return execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
+ cwd: repo,
+ encoding: "utf8",
+ env,
+ }).trim();
}
test("local base resolution uses the branch merge-base and fails without origin/main", () => {
diff --git a/web/package.json b/web/package.json
index a074a581dca..d932a612c8c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,7 +10,7 @@
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
"lint": "biome lint .",
- "check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation",
+ "check": "biome check . && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"preview": "vite preview",
"test:e2e": "pnpm build && playwright test",
From 081910424a5b6f01b283ad632b0718240c6b3cbf Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 07:36:50 -0600
Subject: [PATCH 05/27] fix(desktop): bind presence retry timers (#6213)
## Summary
- preserve the WebKit-required `Window` receiver when scheduling and
clearing presence subscription retries
- add a receiver-sensitive regression test covering both retry creation
and disposal
## Impact
When subscription opening failed, WebKit rejected the detached timer
call before `retryTimer` could be set. The reconciler's `finally` block
then immediately started another reconciliation because demand was still
unsatisfied and no retry appeared pending. This bypassed the intended
exponential backoff and could repeatedly reopen subscription work during
startup, so the impact was more than console noise.
With the timer receiver fixed, a failed open schedules one bounded retry
at a time (1s exponential backoff, capped at 30s), and disposal cancels
it correctly.
## Validation
- `cd desktop && node --import ./test-loader.mjs
--experimental-strip-types --test
src/features/presence/lib/presenceSubscriptionReconciler.test.mjs` (11
passed)
- `cd desktop && pnpm test` (4,993 passed)
- `cd desktop && pnpm typecheck`
- `cd desktop && pnpm check` (passes with existing unrelated warnings)
- pre-push hook on `578b9a0b5c851b21de0b8746238b04dc69032e78` (desktop
check/typecheck/tests, Rust tests, Tauri checks, mobile tests all
passed)
Signed-off-by: Wes
Co-authored-by: Carl
---
.../presenceSubscriptionReconciler.test.mjs | 35 +++++++++++++++++++
.../lib/presenceSubscriptionReconciler.ts | 7 ++--
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs
index f86000dfbf1..19b02498394 100644
--- a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs
+++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs
@@ -75,6 +75,41 @@ test("a stale async open is closed and never installed", async () => {
reconciler.dispose();
});
+test("default timers preserve their global receiver when scheduling retries", async () => {
+ const originalSetTimeout = globalThis.setTimeout;
+ const originalClearTimeout = globalThis.clearTimeout;
+ const timers = [];
+ const cleared = [];
+
+ globalThis.setTimeout = function (callback) {
+ assert.equal(this, globalThis);
+ timers.push(callback);
+ return timers.length;
+ };
+ globalThis.clearTimeout = function (timer) {
+ assert.equal(this, globalThis);
+ cleared.push(timer);
+ };
+
+ try {
+ const reconciler = new PresenceSubscriptionReconciler({
+ open: async () => {
+ throw new Error("relay unavailable");
+ },
+ });
+
+ reconciler.setAuthors([A]);
+ await Promise.resolve();
+ assert.equal(timers.length, 1);
+
+ reconciler.dispose();
+ assert.deepEqual(cleared, [1]);
+ } finally {
+ globalThis.setTimeout = originalSetTimeout;
+ globalThis.clearTimeout = originalClearTimeout;
+ }
+});
+
test("failed replacement preserves the previous subscription and retries", async () => {
const timers = [];
const actions = [];
diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts
index 2831785ccfb..dccadf85444 100644
--- a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts
+++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts
@@ -41,8 +41,11 @@ export class PresenceSubscriptionReconciler {
this.retryDelay =
options.retryDelay ??
((attempt) => Math.min(1000 * 2 ** attempt, 30_000));
- this.setTimer = options.setTimer ?? setTimeout;
- this.clearTimer = options.clearTimer ?? clearTimeout;
+ this.setTimer =
+ options.setTimer ??
+ ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
+ this.clearTimer =
+ options.clearTimer ?? ((timer) => globalThis.clearTimeout(timer));
}
setAuthors(authors: string[]) {
From cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3 Mon Sep 17 00:00:00 2001
From: Logan Johnson
Date: Tue, 18 Aug 2026 11:00:52 -0400
Subject: [PATCH 06/27] fix: bump h2 for RUSTSEC-2026-0258 (#6222)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
🤖
## Summary
- Bump the locked `h2` version from `0.4.14` to `0.4.16` to clear
RUSTSEC-2026-0258, which affects h2 versions through `0.4.15`.
- Keep the change lockfile-only; no manifest or product-code changes are
included.
## Details
- `h2` is now `0.4.16` with checksum
`a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27`.
- The lockfile was regenerated by `cargo update -p h2 --precise 0.4.16`
using Cargo 1.95.0 / rustc 1.95.0, matching `rust-toolchain.toml` and
the CI pin.
- Cargo's canonical resolver output also rewrites 16 dependency
references to existing `windows-sys` package entries:
- `anstyle-query 1.1.5`, `anstyle-wincon 3.0.11`, `socket2 0.6.3`,
`termina 0.3.3`, and `uds_windows 1.2.1`: `0.61.2` → `0.60.2`.
- `dirs-sys 0.5.0` and `nu-ansi-term 0.50.3`: `0.59.0` → `0.60.2`.
- `colored 3.1.1`, `errno 0.3.14`, `quinn-udp 0.5.14`, `rustix 0.38.44`,
`rustix 1.1.4`, `rustls-platform-verifier 0.7.0`, `seize 0.5.1`,
`tempfile 3.27.0`, and `winapi-util 0.1.11`: `0.59.0` → `0.52.0`.
- All four referenced `windows-sys` versions (`0.52.0`, `0.59.0`,
`0.60.2`, and `0.61.2`) were already present in the baseline lockfile.
There are zero new package entries or checksums, and the only package
record replacement is `h2 0.4.14` → `0.4.16`. There is no `wasmtime`
edge change.
## Validation
- `cargo-deny check` passed.
- `cargo build --workspace` passed.
- `just test-unit` passed: 455 passed, 0 failed, 1 ignored; all nine
package suites passed.
Signed-off-by: loganj
---
Cargo.lock | 36 ++++++++++++++++++------------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 6c46beedf2f..16d86d0206f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -117,7 +117,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -1712,7 +1712,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -2544,7 +2544,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -2767,7 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -3338,9 +3338,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.14"
+version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -6028,7 +6028,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -7501,7 +7501,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -8172,7 +8172,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -8185,7 +8185,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -8244,7 +8244,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -8527,7 +8527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -9024,7 +9024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -9652,7 +9652,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -9665,7 +9665,7 @@ dependencies = [
"parking_lot",
"rustix 1.1.4",
"signal-hook",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -10435,7 +10435,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -11019,7 +11019,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.52.0",
]
[[package]]
From 3fdf289b78c40f80abce86575c25b5ed6361d82c Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 09:46:26 -0600
Subject: [PATCH 07/27] fix(desktop): bound remote agent mention authorization
(#6224)
## Summary
- add a targeted native `revalidate_relay_agents(pubkeys, channel_id)`
command for send-time authorization
- scope membership discovery to the destination channel and selected
pubkeys before runtime/profile/policy queries
- replace full relay-directory rebuilds with targeted checks before
agent side effects and again at publication
- preserve managed-agent evidence independently and retain internal
owner-only filtering
## Security and trust boundary
The targeted command reuses the existing authoritative chain:
1. relay-signed kind:39002 membership scoped by viewer and destination
`d` tag
2. agent runtime directory event
3. agent-signed owner profile verification
4. owner-signed managed policy
5. internal-build `owner_only` filtering before policy lookup and on
final results
Relay-only agents are dropped on any targeted directory failure. Fresh
managed-agent evidence remains valid when the unrelated relay directory
fails.
## Validation
- pre-push gate passed: branch skew, Desktop check/typecheck/tests,
Tauri checks, Rust tests, and mobile tests
- Desktop unit suite: 4,992 passed
- targeted Rust relay-directory tests passed
- focused Playwright mention-send acceptance passed
- relay-only send emits the expected `p` tag
- exactly two targeted `revalidate_relay_agents` calls on the
already-member send path: pre-side-effect and pre-publication
- no `list_relay_agents` call on that send path
- relay failure and revocation remain fail-closed
- internal owner-only mode hides other-owned agents
- manual local Desktop testing by Wes: mention sends felt materially
improved
## Notes
This intentionally does not clear the composer early or add an
optimistic timeline row. Publication still waits for fresh
authorization.
---------
Signed-off-by: Wes
Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz>
---
.../src-tauri/src/commands/agent_discovery.rs | 2 +-
.../agent_discovery/relay_directory.rs | 55 +++++++++++---
desktop/src-tauri/src/lib.rs | 2 +-
.../lib/agentMentionRevalidation.test.mjs | 40 +++++-----
.../messages/lib/agentMentionRevalidation.ts | 25 +++---
.../src/features/messages/lib/useMentions.ts | 1 -
desktop/src/shared/api/tauriRelayAgents.ts | 37 +++++++++
desktop/src/testing/e2eBridge.ts | 23 ++++++
desktop/tests/e2e/mentions.spec.ts | 76 ++++++++++++++-----
9 files changed, 192 insertions(+), 69 deletions(-)
create mode 100644 desktop/src/shared/api/tauriRelayAgents.ts
diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs
index e8e910b6451..95534854a0b 100644
--- a/desktop/src-tauri/src/commands/agent_discovery.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery.rs
@@ -1033,7 +1033,7 @@ pub async fn discover_managed_agent_prereqs(
mod relay_directory;
#[cfg(test)]
use relay_directory::advance_relay_cursor;
-pub use relay_directory::list_relay_agents;
+pub use relay_directory::{list_relay_agents, revalidate_relay_agents};
#[cfg(test)]
mod tests {
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 a4da9c475d9..d00969bf19c 100644
--- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
@@ -90,6 +90,14 @@ fn retain_verified_owner(
pub(crate) async fn list_relay_agents_for_state(
state: &AppState,
+) -> Result, String> {
+ list_relay_agents_for_selection(state, None, None).await
+}
+
+async fn list_relay_agents_for_selection(
+ state: &AppState,
+ requested_pubkeys: Option<&std::collections::HashSet>,
+ channel_id: Option<&str>,
) -> Result, String> {
let viewer_pubkey = current_user_pubkey(state)?;
let owner_only = owner_only_relay_directory();
@@ -100,18 +108,22 @@ pub(crate) async fn list_relay_agents_for_state(
// Membership is the authoritative and bounded candidate source. Only
// channels visible to this identity are read, and only bot-role p-tags can
// drive the downstream managed-policy and owner-profile lookups.
- let membership_events = query_all_relay_pages(
- state,
- serde_json::json!({
- "kinds": [39002],
- "authors": [&relay_pubkey],
- "#p": [&viewer_pubkey],
- }),
- )
- .await
- .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?;
- let member_agent_channel_ids =
+ let mut membership_filter = serde_json::json!({
+ "kinds": [39002],
+ "authors": [&relay_pubkey],
+ "#p": [&viewer_pubkey],
+ });
+ if let Some(channel_id) = channel_id {
+ membership_filter["#d"] = serde_json::json!([channel_id]);
+ }
+ let membership_events = query_all_relay_pages(state, membership_filter)
+ .await
+ .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?;
+ let mut member_agent_channel_ids =
nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey);
+ if let Some(requested_pubkeys) = requested_pubkeys {
+ member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey));
+ }
let candidate_pubkeys: Vec = member_agent_channel_ids.keys().cloned().collect();
if candidate_pubkeys.is_empty() {
return Ok(Vec::new());
@@ -186,6 +198,27 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result,
+ channel_id: Option,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let requested_pubkeys = pubkeys
+ .into_iter()
+ .filter_map(|pubkey| nostr::PublicKey::from_hex(&pubkey).ok())
+ .map(|pubkey| pubkey.to_hex())
+ .collect::>();
+ if requested_pubkeys.is_empty() {
+ return Ok(Vec::new());
+ }
+ list_relay_agents_for_selection(&state, Some(&requested_pubkeys), channel_id.as_deref()).await
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index b00d61521bb..cefdccfd69f 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -146,7 +146,6 @@ pub fn run() {
if webview.label() != "main" {
return;
}
-
// Linux/WebKitGTK needs media-stream settings and a
// permission-request handler for getUserMedia; no-op
// on macOS/Windows.
@@ -763,6 +762,7 @@ pub fn run() {
get_relay_self,
resolve_oa_owner,
list_relay_agents,
+ revalidate_relay_agents,
list_managed_agents,
list_managed_agent_runtimes,
start_managed_agent_runtime,
diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs
index d5a57278557..338ce79003a 100644
--- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs
+++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs
@@ -19,17 +19,14 @@ function options(refetchOwnerProfiles) {
ownerOnly: true,
ownerPolicyError: null,
refetchManagedAgents: async () => ({ data: [], error: null }),
- refetchRelayAgents: async () => ({
- data: [
- {
- pubkey: AGENT,
- respondTo: "anyone",
- respondToAllowlist: [],
- channelIds: ["general"],
- },
- ],
- error: null,
- }),
+ fetchRelayAgents: async () => [
+ {
+ pubkey: AGENT,
+ respondTo: "anyone",
+ respondToAllowlist: [],
+ channelIds: ["general"],
+ },
+ ],
refetchOwnerProfiles,
};
}
@@ -61,10 +58,9 @@ test("fresh managed evidence survives unrelated relay authorization errors", asy
data: [{ pubkey: LOCAL_AGENT }],
error: null,
}),
- refetchRelayAgents: async () => ({
- data: undefined,
- error: new Error("relay directory unavailable"),
- }),
+ fetchRelayAgents: async () => {
+ throw new Error("relay directory unavailable");
+ },
});
assert.deepEqual(result, [HUMAN, LOCAL_AGENT]);
@@ -76,10 +72,9 @@ test("relay-only agents still fail closed when relay discovery fails", async ()
profiles: { [AGENT]: { ownerPubkey: CURRENT } },
missing: [],
})),
- refetchRelayAgents: async () => ({
- data: undefined,
- error: new Error("relay directory unavailable"),
- }),
+ fetchRelayAgents: async () => {
+ throw new Error("relay directory unavailable");
+ },
});
assert.deepEqual(result, [HUMAN]);
@@ -97,10 +92,9 @@ test("mixed evidence preserves only fresh managed agents and humans", async () =
data: [{ pubkey: LOCAL_AGENT }],
error: null,
}),
- refetchRelayAgents: async () => ({
- data: undefined,
- error: new Error("relay directory unavailable"),
- }),
+ fetchRelayAgents: async () => {
+ throw new Error("relay directory unavailable");
+ },
});
assert.deepEqual(result, [HUMAN, LOCAL_AGENT]);
diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts
index 1e6b3a7d669..0eaf26f401a 100644
--- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts
+++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts
@@ -6,6 +6,7 @@ import {
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { evictUsersBatchEntries } from "@/features/profile/hooks";
import { getUsersBatch } from "@/shared/api/tauriProfiles";
+import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents";
import type {
ManagedAgent,
RelayAgent,
@@ -29,7 +30,7 @@ export async function revalidateAgentMentionPubkeys({
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
- refetchRelayAgents,
+ fetchRelayAgents,
refetchOwnerProfiles,
}: {
pubkeys: readonly string[];
@@ -40,7 +41,7 @@ export async function revalidateAgentMentionPubkeys({
ownerOnly: boolean | undefined;
ownerPolicyError: Error | null;
refetchManagedAgents: () => Promise>;
- refetchRelayAgents: () => Promise>;
+ fetchRelayAgents: (pubkeys: string[]) => Promise;
refetchOwnerProfiles: (pubkeys: string[]) => Promise;
}) {
const requestedAgentPubkeys = new Set(
@@ -50,15 +51,14 @@ export async function revalidateAgentMentionPubkeys({
return [...pubkeys];
}
- const [managedResult, relayResult, ownerProfiles] = await Promise.all([
+ const [managedResult, relayAgents, ownerProfiles] = await Promise.all([
refetchManagedAgents(),
- refetchRelayAgents(),
+ fetchRelayAgents([...requestedAgentPubkeys]).catch(() => null),
ownerOnly
? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null)
: Promise.resolve(null),
]);
- const relayDirectoryReady =
- relayResult.error === null && relayResult.data !== undefined;
+ const relayDirectoryReady = relayAgents !== null;
if (
ownerOnly === undefined ||
ownerPolicyError !== null ||
@@ -75,7 +75,7 @@ export async function revalidateAgentMentionPubkeys({
currentPubkey,
eligibilityScope,
managedAgentPubkeys: managedPubkeys,
- relayAgents: relayDirectoryReady ? relayResult.data : [],
+ relayAgents: relayDirectoryReady ? relayAgents : [],
sharedChannelIds,
});
const admittedPubkeys = new Set(
@@ -110,7 +110,6 @@ export function useAgentMentionRevalidation({
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
- refetchRelayAgents,
}: {
agentPubkeys: ReadonlySet;
getSelectedAgentPubkeys: () => ReadonlySet;
@@ -120,7 +119,6 @@ export function useAgentMentionRevalidation({
ownerOnly: boolean | undefined;
ownerPolicyError: Error | null;
refetchManagedAgents: () => Promise>;
- refetchRelayAgents: () => Promise>;
}) {
const queryClient = useQueryClient();
const refetchOwnerProfiles = React.useCallback(
@@ -141,7 +139,13 @@ export function useAgentMentionRevalidation({
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
- refetchRelayAgents,
+ fetchRelayAgents: (requestedPubkeys) =>
+ revalidateRelayAgents(
+ requestedPubkeys,
+ eligibilityScope.type === "channel"
+ ? eligibilityScope.channelId
+ : undefined,
+ ),
refetchOwnerProfiles,
}),
[
@@ -153,7 +157,6 @@ export function useAgentMentionRevalidation({
ownerPolicyError,
refetchManagedAgents,
refetchOwnerProfiles,
- refetchRelayAgents,
sharedChannelIds,
],
);
diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts
index fbf59e4c958..160d999a4d9 100644
--- a/desktop/src/features/messages/lib/useMentions.ts
+++ b/desktop/src/features/messages/lib/useMentions.ts
@@ -827,7 +827,6 @@ export function useMentions(
ownerOnly: agentAccessOwnerOnlyQuery.data,
ownerPolicyError: agentAccessOwnerOnlyQuery.error,
refetchManagedAgents: managedAgentsQuery.refetch,
- refetchRelayAgents: relayAgentsQuery.refetch,
});
const extractMentionPersonas = React.useCallback(
diff --git a/desktop/src/shared/api/tauriRelayAgents.ts b/desktop/src/shared/api/tauriRelayAgents.ts
new file mode 100644
index 00000000000..8ae6766f79a
--- /dev/null
+++ b/desktop/src/shared/api/tauriRelayAgents.ts
@@ -0,0 +1,37 @@
+import { invokeTauri } from "@/shared/api/tauri";
+import type { RelayAgent } from "@/shared/api/types";
+
+type RawRelayAgent = {
+ pubkey: string;
+ owner_pubkey?: string | null;
+ name: string;
+ agent_type: string;
+ channels: string[];
+ channel_ids: string[];
+ capabilities: string[];
+ status: RelayAgent["status"];
+ respond_to?: RelayAgent["respondTo"];
+ respond_to_allowlist?: string[];
+};
+
+export async function revalidateRelayAgents(
+ pubkeys: string[],
+ channelId?: string,
+): Promise {
+ const agents = await invokeTauri("revalidate_relay_agents", {
+ pubkeys,
+ channelId,
+ });
+ return agents.map((agent) => ({
+ pubkey: agent.pubkey,
+ ownerPubkey: agent.owner_pubkey ?? null,
+ name: agent.name,
+ agentType: agent.agent_type,
+ channels: agent.channels,
+ channelIds: agent.channel_ids ?? [],
+ capabilities: agent.capabilities,
+ status: agent.status,
+ respondTo: agent.respond_to ?? null,
+ respondToAllowlist: agent.respond_to_allowlist ?? [],
+ }));
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1239f9522a3..1aa98ca4a7f 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -298,6 +298,8 @@ type E2eConfig = {
relayAgents?: MockRelayAgentSeed[];
/** Reject successive relay-agent directory reads, then resume. */
relayAgentListErrors?: (string | null)[];
+ /** Pubkeys omitted only from targeted send-time authorization checks. */
+ relayAgentRevalidationRevokedPubkeys?: string[];
/** Native-like huddle state seeded from authoritative role-bearing membership. */
huddle?: MockHuddleSeed;
agentListDelayMs?: number;
@@ -12213,6 +12215,27 @@ export function maybeInstallE2eTauriMocks() {
);
case "list_relay_agents":
return handleListRelayAgents(activeConfig);
+ case "revalidate_relay_agents": {
+ const agents = await handleListRelayAgents(activeConfig);
+ const { pubkeys, channelId } = payload as {
+ pubkeys: string[];
+ channelId?: string;
+ };
+ const requested = new Set(
+ pubkeys.map((pubkey) => pubkey.toLowerCase()),
+ );
+ const revoked = new Set(
+ (activeConfig?.mock?.relayAgentRevalidationRevokedPubkeys ?? []).map(
+ (pubkey) => pubkey.toLowerCase(),
+ ),
+ );
+ return agents.filter(
+ (agent) =>
+ requested.has(agent.pubkey.toLowerCase()) &&
+ !revoked.has(agent.pubkey.toLowerCase()) &&
+ (!channelId || agent.channel_ids.includes(channelId)),
+ );
+ }
case "list_personas":
return handleListPersonas();
case "create_persona":
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 5562920a349..e6e0e9806e4 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1198,6 +1198,24 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({
});
await page.goto("/");
await page.getByTestId("channel-general").click();
+ await page.evaluate(
+ async ({ channelId, pubkey }) => {
+ const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
+ if (!invoke) throw new Error("Mock bridge is not installed.");
+ await invoke("add_channel_members", {
+ channelId,
+ pubkeys: [pubkey],
+ role: "bot",
+ });
+ await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
+ queryKey: ["channels"],
+ });
+ },
+ {
+ channelId: GENERAL_CHANNEL_ID,
+ pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
+ },
+ );
const input = page.getByTestId("message-input");
await input.fill("@quinn");
@@ -1206,12 +1224,20 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({
await quinnRow.click();
await page.keyboard.type("hello");
await expect(input).toHaveText("@quinn hello");
+ const baselineCommands = await readCommandLog(page);
await page.getByTestId("send-message").click();
- await page.getByRole("button", { name: "Invite", exact: true }).click();
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
+
+ const commands = await readCommandLog(page);
+ expect(commandCount(commands, "revalidate_relay_agents")).toBe(
+ commandCount(baselineCommands, "revalidate_relay_agents") + 2,
+ );
+ expect(commandCount(commands, "list_relay_agents")).toBe(
+ commandCount(baselineCommands, "list_relay_agents"),
+ );
});
test("managed agents keep their p tag when relay discovery fails before send", async ({
@@ -1260,7 +1286,7 @@ test("managed agents keep their p tag when relay discovery fails before send", a
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
});
-test("selected relay agents revoked before send emit no p tag", async ({
+test("targeted revocation before send causes no agent side effects", async ({
page,
}) => {
await installMockBridge(page, {
@@ -1276,6 +1302,24 @@ test("selected relay agents revoked before send emit no p tag", async ({
});
await page.goto("/");
await page.getByTestId("channel-general").click();
+ await page.evaluate(
+ async ({ channelId, pubkey }) => {
+ const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
+ if (!invoke) throw new Error("Mock bridge is not installed.");
+ await invoke("add_channel_members", {
+ channelId,
+ pubkeys: [pubkey],
+ role: "bot",
+ });
+ await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
+ queryKey: ["channels"],
+ });
+ },
+ {
+ channelId: GENERAL_CHANNEL_ID,
+ pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
+ },
+ );
const input = page.getByTestId("message-input");
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
@@ -1283,26 +1327,10 @@ test("selected relay agents revoked before send emit no p tag", async ({
await quinnRow.click();
await page.keyboard.type("hello");
- await page.evaluate(async () => {
+ await page.evaluate((pubkey) => {
window.__BUZZ_E2E__.mock ??= {};
- window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
- "mock directory revoked",
- );
- const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
- invalidateQueries: (filters: {
- queryKey: readonly unknown[];
- }) => Promise;
- getQueryState: (
- queryKey: readonly unknown[],
- ) => { status?: string } | undefined;
- };
- await queryClient.invalidateQueries({ queryKey: ["relay-agents"] });
- if (queryClient.getQueryState(["relay-agents"])?.status !== "error") {
- throw new Error(
- "relay-agent directory refetch did not enter error state",
- );
- }
- });
+ window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
+ }, ALLOWLIST_RELAY_AGENT_PUBKEY);
const baselineCommands = await readCommandLog(page);
await page.getByTestId("send-message").click();
@@ -1313,6 +1341,12 @@ test("selected relay agents revoked before send emit no p tag", async ({
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
const commands = await readCommandLog(page);
+ expect(commandCount(commands, "revalidate_relay_agents")).toBe(
+ commandCount(baselineCommands, "revalidate_relay_agents") + 2,
+ );
+ expect(commandCount(commands, "list_relay_agents")).toBe(
+ commandCount(baselineCommands, "list_relay_agents"),
+ );
for (const command of [
"add_channel_members",
"start_managed_agent",
From 5694e78def8b6ea674e101c1c988a5f17c9baf9d Mon Sep 17 00:00:00 2001
From: Salman Mohammed
Date: Tue, 18 Aug 2026 12:04:48 -0400
Subject: [PATCH 08/27] fix(mcp): scope todo usage (#6216)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
The `todo` tool description does not say when the tool is unnecessary,
so agents use it for single-turn bookkeeping. Scoping it to cross-turn
persistence reduces avoidable control calls while preserving the
checklist for compaction and genuinely multi-turn work.
## What
- Scope `todo` to work that must continue across turns or survive
context compaction
- Tell agents not to use it for work they can finish in the current turn
- Preserve read/replace semantics and the `_Stop` hook behavior
## Risk Assessment
Low to medium — this changes agent tool-selection guidance, not the tool
name, schema, or implementation. The benchmark covers single-turn
completion but not restart, compaction, or long-lived multi-turn
recovery.
## References
- Companion base-prompt change: https://github.com/block/buzz/pull/6186
- Combined benchmark (PR 6186 prompt plus this description): 22/22 pass;
active time 0.3438h → 0.2680h (-22.0%); tool calls 216 → 173 (-19.9%);
todo calls 45 → 0
- Against PR 6186's prompt-only condition: active time 0.2926h → 0.2680h
(-8.4%); tool calls 198 → 173 (-12.6%)
- Results are directional because model and tool behavior is stochastic.
Generated with Codex
Signed-off-by: Salman Mohammed
---
crates/buzz-dev-mcp/src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs
index 9b98974802f..87c3a119317 100644
--- a/crates/buzz-dev-mcp/src/lib.rs
+++ b/crates/buzz-dev-mcp/src/lib.rs
@@ -84,7 +84,7 @@ impl DevMcp {
#[tool(
name = "todo",
- description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open."
+ description = "Session checklist only for work that must continue across turns or survive context compaction. Do not use for work you can finish in the current turn. Omit `todos` to read; provide the full {text, done} list to replace it. Open items let the _Stop hook advise against ending."
)]
async fn todo(
&self,
From d2cfd377e27dab8fdef0236dd8e92c89efbae829 Mon Sep 17 00:00:00 2001
From: Salman Mohammed
Date: Tue, 18 Aug 2026 12:24:12 -0400
Subject: [PATCH 09/27] fix(prompt): simplify pickup follow-through (#6186)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
The base prompt prescribed a todo lifecycle for every task needing
follow-up tools, which added control calls even for work completed in
one turn. This keeps the important behavioral contract—continue after
publishing pickup—without prescribing the mechanism.
## What
- Replace the 40-word todo lifecycle with a concise pickup
follow-through rule
- Preserve the requirement to publish the outcome or blocker before
stopping
## Risk Assessment
Low to medium — this changes managed-agent instructions, not runtime
code. The terminal benchmark covers single-turn task completion but does
not cover restart, compaction, or long-lived multi-turn recovery.
## References
- Builds on the prompt simplification in
https://github.com/block/buzz/pull/6161
- Benchmark setup: GPT 5.6 Terra at high effort; the same 11-task
Terminal-Bench 2.1 slate; four concurrent trials; 4 CPU and 8 GiB per
trial; 3× timeout
| Prompt | Pass | Active-h | Median active | Tool calls |
|---|---:|---:|---:|---:|
| PR 6161 baseline | 22/22 | 0.3438 | 0.91 min | 216 |
| Benchmarked 14-word rule | 22/22 | 0.2926 | 0.75 min | 198 |
The benchmarked rule used 14.9% less active time, 16.9% lower median
active time, and 8.3% fewer tool calls. Across the screen and
confirmation runs it passed 33/33 trials with every completion report
present; results are directional because model and tool behavior is
stochastic.
---
**Update Aug 18, 10:47 EDT:** Expanded the completion outcomes following
review feedback.
- The follow-through rule now covers a verified result, blocker, or key
decision or information that needs to be surfaced.
- The benchmark was not rerun; the table reflects the prior 14-word
formulation. This is a completion-taxonomy clarification, not a return
to a prescribed todo mechanism.
Generated with Codex
Signed-off-by: Salman Mohammed
---
crates/buzz-acp/src/base_prompt.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md
index a746e217628..f2de6983282 100644
--- a/crates/buzz-acp/src/base_prompt.md
+++ b/crates/buzz-acp/src/base_prompt.md
@@ -80,7 +80,7 @@ All replies and delegations — including task assignments to other agents — g
- **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure.
- **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed.
- **Never publish a bare acknowledgement.** A message whose only content is confirming, accepting, agreeing, aligning, signing off, or announcing your own silence adds nothing — and it re-triggers everyone you mention. Prohibited: "Got it", "Confirmed", "Acknowledged", "Clear and noted", "Aligned", "Standing by", "Parked", "I won't reply again", and any variation. If your draft contains nothing beyond acknowledgement, send nothing. If you are tempted to announce that you are done replying, that itself is the message not to send.
-- For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message.
+- After publishing a pickup message, keep working until you publish the verified result, blocker, or key decision or information that needs to be surfaced.
- Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting.
- No push notifications — poll with `buzz messages get --channel --since `.
- Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them.
From 8232299cbe6d90692fac3de46cde0ec123edd6c1 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 10:40:31 -0600
Subject: [PATCH 10/27] chore(release): release Buzz Desktop version 0.5.17
(#6234)
## Buzz Desktop release v0.5.17
- **Frozen main:** `3fdf289b78c40f80abce86575c25b5ed6361d82c`
- **Reviewed candidate:** `c3bfd66947978fae93f4cfb46bea98ba20e32ccf`
- **Previous desktop release:** `desktop-v0.5.16`
- **Proposed immutable tag:** `desktop-v0.5.17`
This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.
The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.
Signed-off-by: Wes
Co-authored-by: Release Automation
---
.release/desktop-candidate.json | 14 +++++++-------
CHANGELOG.md | 15 +++++++++++++++
desktop/package.json | 2 +-
desktop/src-tauri/Cargo.lock | 2 +-
desktop/src-tauri/Cargo.toml | 2 +-
desktop/src-tauri/tauri.conf.json | 2 +-
6 files changed, 26 insertions(+), 11 deletions(-)
diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json
index 2cfec8d7813..3920dfeb44f 100644
--- a/.release/desktop-candidate.json
+++ b/.release/desktop-candidate.json
@@ -1,10 +1,10 @@
{
"schema": 2,
- "version": "0.5.16",
- "base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386",
- "previous_tag": "desktop-v0.5.15",
- "previous_base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2",
- "previous_merge_sha": "c8c8eb58ad5336f21d77e7b02517cd4604a9a7ae",
- "tag": "desktop-v0.5.16",
- "commit_count": 2
+ "version": "0.5.17",
+ "base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c",
+ "previous_tag": "desktop-v0.5.16",
+ "previous_base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386",
+ "previous_merge_sha": "978e585e8df893fe55aded854de07996b9412678",
+ "tag": "desktop-v0.5.17",
+ "commit_count": 5
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f81baf7730..bec9b9324e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## v0.5.17
+
+### Desktop and shared changes
+
+- fix(desktop): bound remote agent mention authorization ([#6224](https://github.com/block/buzz/pull/6224)) ([`3fdf289b78c40f80abce86575c25b5ed6361d82c`](https://github.com/block/buzz/commit/3fdf289b78c40f80abce86575c25b5ed6361d82c))
+- fix(desktop): bind presence retry timers ([#6213](https://github.com/block/buzz/pull/6213)) ([`081910424a5b6f01b283ad632b0718240c6b3cbf`](https://github.com/block/buzz/commit/081910424a5b6f01b283ad632b0718240c6b3cbf))
+- ci: make file-size policy a first-class gate ([#6187](https://github.com/block/buzz/pull/6187)) ([`6d45f98665004d314468d98e50084996f4046cdf`](https://github.com/block/buzz/commit/6d45f98665004d314468d98e50084996f4046cdf))
+- fix(desktop): eliminate mounted-view CPU burn — compositor-safe shimmer, observer append fast path, poll-tick disk reads ([#6198](https://github.com/block/buzz/pull/6198)) ([`f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9`](https://github.com/block/buzz/commit/f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9))
+
+### Other repository changes
+
+- fix: bump h2 for RUSTSEC-2026-0258 ([#6222](https://github.com/block/buzz/pull/6222)) ([`cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3`](https://github.com/block/buzz/commit/cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3))
+
+[Compare desktop-v0.5.16...desktop-v0.5.17](https://github.com/block/buzz/compare/desktop-v0.5.16...desktop-v0.5.17)
+
## v0.5.16
### Desktop and shared changes
diff --git a/desktop/package.json b/desktop/package.json
index 073a764a1ba..683c1529d25 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
- "version": "0.5.16",
+ "version": "0.5.17",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 8724145dfd5..081e345edb7 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
-version = "0.5.16"
+version = "0.5.17"
dependencies = [
"anyhow",
"arboard",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 1fa31d12d35..01504852b6f 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"]
[package]
name = "buzz-desktop"
-version = "0.5.16"
+version = "0.5.17"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index 884cf0624f3..b6cbaab514f 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Buzz",
- "version": "0.5.16",
+ "version": "0.5.17",
"identifier": "xyz.block.buzz.app",
"build": {
"beforeDevCommand": {
From 417eea2230c1864e8c77f6440dbcfa109bfb63f6 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 18 Aug 2026 18:56:58 +0100
Subject: [PATCH 11/27] Polish mobile timeline navigation (#5874)
## Summary
- add sticky date headers that crossfade with in-timeline dates
- replace the Latest pill with a centered down-arrow control
- animate the control out from its bottom-center anchor
## Validation
- `bin/just mobile-check`
- `bin/just mobile-test` (1,359 tests)
- Android debug and signed iOS release builds
---------
Signed-off-by: kenny lopez
Signed-off-by: Kenny Lopez
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
---
mobile/ios/Runner.xcodeproj/project.pbxproj | 8 +
mobile/ios/Runner/AppDelegate.swift | 18 +
.../ios/Runner/JumpToLatestGlassButton.swift | 125 ++++++
mobile/ios/Runner/StickyDateGlassHeader.swift | 131 ++++++
.../channels/channel_detail_page.dart | 5 +-
.../channel_detail_page/message_list.dart | 393 ++++++++++++++++--
mobile/lib/features/channels/day_divider.dart | 81 ++--
.../channels/jump_to_latest_button.dart | 112 +++++
.../features/channels/sticky_date_header.dart | 210 ++++++++++
.../channels/channel_detail_page_test.dart | 171 +++++++-
.../features/channels/day_divider_test.dart | 44 ++
.../channels/jump_to_latest_button_test.dart | 76 ++++
.../channels/sticky_date_header_test.dart | 117 ++++++
13 files changed, 1399 insertions(+), 92 deletions(-)
create mode 100644 mobile/ios/Runner/JumpToLatestGlassButton.swift
create mode 100644 mobile/ios/Runner/StickyDateGlassHeader.swift
create mode 100644 mobile/lib/features/channels/jump_to_latest_button.dart
create mode 100644 mobile/lib/features/channels/sticky_date_header.dart
create mode 100644 mobile/test/features/channels/day_divider_test.dart
create mode 100644 mobile/test/features/channels/jump_to_latest_button_test.dart
create mode 100644 mobile/test/features/channels/sticky_date_header_test.dart
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 7ccf13bae4d..45cf085cb79 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -14,6 +14,8 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
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 */; };
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 */; };
@@ -59,6 +61,8 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = ""; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; };
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 = ""; };
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; };
@@ -182,6 +186,8 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
+ 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
+ 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -417,6 +423,8 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
+ 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
+ 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.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 6ab55c359c7..fa68e539dd0 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -88,6 +88,24 @@ import UserNotifications
}
}
+ if let jumpToLatestGlassRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzJumpToLatestGlassButton"
+ ) {
+ jumpToLatestGlassRegistrar.register(
+ JumpToLatestGlassButtonFactory(messenger: messenger),
+ withId: "buzz/jump_to_latest_glass"
+ )
+ }
+
+ if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzStickyDateGlassHeader"
+ ) {
+ stickyDateGlassRegistrar.register(
+ StickyDateGlassHeaderFactory(messenger: messenger),
+ withId: "buzz/sticky_date_glass"
+ )
+ }
+
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift
new file mode 100644
index 00000000000..0e96a78ec0c
--- /dev/null
+++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift
@@ -0,0 +1,125 @@
+import Flutter
+import UIKit
+
+final class JumpToLatestGlassButtonFactory: 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 {
+ JumpToLatestGlassButtonPlatformView(
+ frame: frame,
+ viewIdentifier: viewId,
+ arguments: args,
+ messenger: messenger
+ )
+ }
+}
+
+private final class JumpToLatestGlassButton: UIButton {
+ private static let hitTargetExpansion: CGFloat = 4
+
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
+ bounds
+ .insetBy(
+ dx: -Self.hitTargetExpansion,
+ dy: -Self.hitTargetExpansion
+ )
+ .contains(point)
+ }
+}
+
+final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView {
+ private let containerView: UIView
+ private let channel: FlutterMethodChannel
+ private let button = JumpToLatestGlassButton(type: .system)
+
+ init(
+ frame: CGRect,
+ viewIdentifier viewId: Int64,
+ arguments args: Any?,
+ messenger: FlutterBinaryMessenger
+ ) {
+ containerView = UIView(frame: frame)
+ channel = FlutterMethodChannel(
+ name: "buzz/jump_to_latest_glass/\(viewId)",
+ binaryMessenger: messenger
+ )
+ super.init()
+
+ containerView.backgroundColor = .clear
+ containerView.isOpaque = false
+ applyBrightness(from: args)
+
+ var configuration: UIButton.Configuration
+ if #available(iOS 26.0, *) {
+ configuration = .glass()
+ } else {
+ configuration = .gray()
+ configuration.baseBackgroundColor = UIColor.secondarySystemBackground
+ }
+ configuration.cornerStyle = .capsule
+ configuration.baseForegroundColor = .label
+ configuration.image = UIImage(
+ systemName: "arrow.down",
+ withConfiguration: UIImage.SymbolConfiguration(
+ pointSize: 16,
+ weight: .semibold
+ )
+ )
+ button.configuration = configuration
+ button.accessibilityLabel = "Jump to latest message"
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.addAction(
+ UIAction { [weak self] _ in
+ self?.channel.invokeMethod("pressed", arguments: nil)
+ },
+ for: .touchUpInside
+ )
+
+ channel.setMethodCallHandler { [weak self] call, result in
+ guard call.method == "setBrightness" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ self?.applyBrightness(from: call.arguments)
+ result(nil)
+ }
+
+ containerView.addSubview(button)
+ NSLayoutConstraint.activate([
+ button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
+ button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ button.widthAnchor.constraint(equalToConstant: 40),
+ button.heightAnchor.constraint(equalToConstant: 40),
+ ])
+ }
+
+ func view() -> UIView {
+ containerView
+ }
+
+ private func applyBrightness(from value: Any?) {
+ let brightness = (value as? [String: Any])?["brightness"] as? String
+ ?? value as? String
+ let interfaceStyle: UIUserInterfaceStyle = brightness == "dark" ? .dark : .light
+ containerView.overrideUserInterfaceStyle = interfaceStyle
+ button.overrideUserInterfaceStyle = interfaceStyle
+ button.setNeedsUpdateConfiguration()
+ }
+
+ deinit {
+ channel.setMethodCallHandler(nil)
+ }
+}
diff --git a/mobile/ios/Runner/StickyDateGlassHeader.swift b/mobile/ios/Runner/StickyDateGlassHeader.swift
new file mode 100644
index 00000000000..6a502048a20
--- /dev/null
+++ b/mobile/ios/Runner/StickyDateGlassHeader.swift
@@ -0,0 +1,131 @@
+import Flutter
+import UIKit
+
+private final class StickyDateGlassView: UIVisualEffectView {
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ layer.cornerRadius = bounds.height / 2
+ }
+}
+
+final class StickyDateGlassHeaderFactory: 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 {
+ StickyDateGlassHeaderPlatformView(
+ frame: frame,
+ viewIdentifier: viewId,
+ arguments: args,
+ messenger: messenger
+ )
+ }
+}
+
+final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView {
+ private let glassView: StickyDateGlassView
+ private let channel: FlutterMethodChannel
+ private let dateLabel = UILabel()
+
+ init(
+ frame: CGRect,
+ viewIdentifier viewId: Int64,
+ arguments args: Any?,
+ messenger: FlutterBinaryMessenger
+ ) {
+ let arguments = args as? [String: Any]
+ let text = arguments?["label"] as? String ?? ""
+ channel = FlutterMethodChannel(
+ name: "buzz/sticky_date_glass/\(viewId)",
+ binaryMessenger: messenger
+ )
+
+ if #available(iOS 26.0, *) {
+ let glassEffect = UIGlassEffect(style: .regular)
+ glassEffect.isInteractive = false
+ glassView = StickyDateGlassView(effect: glassEffect)
+ } else {
+ glassView = StickyDateGlassView(
+ effect: UIBlurEffect(style: .systemMaterial)
+ )
+ }
+
+ super.init()
+
+ glassView.frame = frame
+ glassView.isOpaque = false
+ glassView.isUserInteractionEnabled = false
+ glassView.clipsToBounds = true
+ glassView.layer.cornerCurve = .continuous
+ applyBrightness(from: arguments?["brightness"])
+
+ dateLabel.translatesAutoresizingMaskIntoConstraints = false
+ dateLabel.text = text
+ dateLabel.textAlignment = .center
+ dateLabel.textColor = .secondaryLabel
+ dateLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(
+ for: UIFont.systemFont(ofSize: 14, weight: .medium)
+ )
+ dateLabel.adjustsFontForContentSizeCategory = true
+ dateLabel.numberOfLines = 1
+ dateLabel.lineBreakMode = .byTruncatingTail
+ dateLabel.isAccessibilityElement = false
+
+ glassView.contentView.addSubview(dateLabel)
+ NSLayoutConstraint.activate([
+ dateLabel.leadingAnchor.constraint(
+ equalTo: glassView.contentView.leadingAnchor,
+ constant: 12
+ ),
+ dateLabel.trailingAnchor.constraint(
+ equalTo: glassView.contentView.trailingAnchor,
+ constant: -12
+ ),
+ dateLabel.centerYAnchor.constraint(
+ equalTo: glassView.contentView.centerYAnchor
+ ),
+ ])
+
+ channel.setMethodCallHandler { [weak self] call, result in
+ switch call.method {
+ case "setLabel":
+ guard let text = call.arguments as? String else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ self?.dateLabel.text = text
+ result(nil)
+ case "setBrightness":
+ self?.applyBrightness(from: call.arguments)
+ result(nil)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+
+ func view() -> UIView {
+ glassView
+ }
+
+ private func applyBrightness(from value: Any?) {
+ let interfaceStyle: UIUserInterfaceStyle = value as? String == "dark" ? .dark : .light
+ glassView.overrideUserInterfaceStyle = interfaceStyle
+ }
+
+ deinit {
+ channel.setMethodCallHandler(nil)
+ }
+}
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 8cef1a78ff7..6e7449a6333 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -1,5 +1,5 @@
import 'dart:async';
-import 'dart:math' show min;
+import 'dart:math' show max, min;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
@@ -45,7 +45,7 @@ import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'ime_metrics_settle_observer.dart';
-import 'latest_message_button.dart';
+import 'jump_to_latest_button.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
@@ -58,6 +58,7 @@ import 'reaction_row.dart';
import 'send_message_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
+import 'sticky_date_header.dart';
import 'thread_detail_page.dart';
import 'timeline_message.dart';
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 57a621fbaf6..59e4c746fb8 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -39,6 +39,11 @@ class _MessageList extends HookConsumerWidget {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
+ final stickyDateHeaderState = useValueNotifier(
+ StickyDateHeaderState.hidden,
+ );
+ final stickyDayTimestamp = useValueNotifier(null);
+ final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final settledImeBottomInset = useState(
@@ -46,7 +51,10 @@ class _MessageList extends HookConsumerWidget {
? appView.viewInsets.bottom / appView.devicePixelRatio
: 0.0,
);
+ final isJumpToLatestVisible = useState(false);
final hasUserScrolled = useState(false);
+ final distanceFromLatest = useRef(0.0);
+ final hasUnseenLatestEntry = useRef(false);
final followsLatest = useState(
initialMessageId == null && initialThreadRootId == null,
);
@@ -65,6 +73,9 @@ class _MessageList extends HookConsumerWidget {
final hasUnreadDeepLink =
initialMessageId != null || initialThreadRootId != null;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
+ final dayTimestampByReversedIndex = {};
+ final dayStartByReversedIndex = {};
+ final dayHeaderTimestampByReversedIndex = {};
final settledImeLift = usesFixedAndroidImeViewport
? (settledImeBottomInset.value -
MediaQuery.viewPaddingOf(context).bottom)
@@ -74,6 +85,35 @@ class _MessageList extends HookConsumerWidget {
final timelineBottomInset =
composerBottomInset + (followsLatest.value ? settledImeLift : 0);
final navigationBottomInset = composerBottomInset + settledImeLift;
+ var currentDayTimestamp =
+ displayEntries.firstOrNull?.first.message.createdAt;
+ var currentDayStartIndex = displayEntries.isEmpty
+ ? -1
+ : displayEntries.length - 1;
+ for (
+ var chronologicalIndex = 0;
+ chronologicalIndex < displayEntries.length;
+ chronologicalIndex += 1
+ ) {
+ final message = displayEntries[chronologicalIndex].first.message;
+ final previousMessage = chronologicalIndex > 0
+ ? displayEntries[chronologicalIndex - 1].last.message
+ : null;
+ final startsDay =
+ previousMessage == null ||
+ !isSameDay(previousMessage.createdAt, message.createdAt);
+ final reversedIndex = displayEntries.length - 1 - chronologicalIndex;
+ if (startsDay) {
+ currentDayTimestamp = message.createdAt;
+ currentDayStartIndex = reversedIndex;
+ dayHeaderTimestampByReversedIndex[reversedIndex] = message.createdAt;
+ }
+ final dayTimestamp = currentDayTimestamp;
+ if (dayTimestamp != null) {
+ dayTimestampByReversedIndex[reversedIndex] = dayTimestamp;
+ dayStartByReversedIndex[reversedIndex] = currentDayStartIndex;
+ }
+ }
useEffect(
() {
@@ -172,12 +212,120 @@ class _MessageList extends HookConsumerWidget {
}
double latestAlignment() {
- final viewportHeight = context.size?.height ?? 0;
+ final viewportHeight = timelineViewportHeight.value;
return viewportHeight > 0
? (timelineBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
: 0.0;
}
+ void updateStickyDateHeader(Iterable rawPositions) {
+ void setStickyDateHeader(
+ StickyDateHeaderState state, {
+ int? activeDayTimestamp,
+ }) {
+ stickyDateHeaderState.value = state;
+ stickyDayTimestamp.value = activeDayTimestamp;
+ }
+
+ final viewportHeight = timelineViewportHeight.value;
+ if (viewportHeight <= 0 || displayEntries.isEmpty) {
+ setStickyDateHeader(StickyDateHeaderState.hidden);
+ return;
+ }
+
+ final positions = rawPositions
+ .where(
+ (position) =>
+ position.index < displayEntries.length &&
+ position.itemLeadingEdge < 1 &&
+ position.itemTrailingEdge > 0,
+ )
+ .toList();
+ if (positions.isEmpty) {
+ if (!isLoadingOlder.value) {
+ setStickyDateHeader(StickyDateHeaderState.hidden);
+ }
+ return;
+ }
+
+ final stickyTop =
+ frostedAppBarHeight(
+ context,
+ titleContentHeight: appBarTitleContentHeight,
+ ) +
+ Grid.twelve;
+ double physicalTop(ItemPosition position) =>
+ viewportHeight * (1 - position.itemTrailingEdge);
+ double physicalBottom(ItemPosition position) =>
+ viewportHeight * (1 - position.itemLeadingEdge);
+
+ final positionAtStickyTop = positions
+ .where(
+ (position) =>
+ physicalTop(position) <= stickyTop &&
+ physicalBottom(position) > stickyTop,
+ )
+ .firstOrNull;
+ if (positionAtStickyTop == null) {
+ if (!isLoadingOlder.value) {
+ setStickyDateHeader(StickyDateHeaderState.hidden);
+ }
+ return;
+ }
+
+ final activeDayTimestamp =
+ dayTimestampByReversedIndex[positionAtStickyTop.index];
+ final activeDayStartIndex =
+ dayStartByReversedIndex[positionAtStickyTop.index];
+ if (activeDayTimestamp == null || activeDayStartIndex == null) {
+ setStickyDateHeader(StickyDateHeaderState.hidden);
+ return;
+ }
+
+ final activeHeaderPosition = positions
+ .where((position) => position.index == activeDayStartIndex)
+ .firstOrNull;
+ final oldestVisibleIndex = positions
+ .map((position) => position.index)
+ .reduce((a, b) => a > b ? a : b);
+ final activeHeaderHasCrossed = activeHeaderPosition != null
+ ? physicalTop(activeHeaderPosition) <= stickyTop
+ : activeDayStartIndex > oldestVisibleIndex;
+ if (!activeHeaderHasCrossed) {
+ setStickyDateHeader(StickyDateHeaderState.hidden);
+ return;
+ }
+
+ double? nextHeaderTop;
+ for (final position in positions) {
+ if (!dayHeaderTimestampByReversedIndex.containsKey(position.index) ||
+ position.index >= activeDayStartIndex) {
+ continue;
+ }
+ final top = physicalTop(position);
+ if (top <= stickyTop ||
+ (nextHeaderTop != null && top >= nextHeaderTop)) {
+ continue;
+ }
+ nextHeaderTop = top;
+ }
+
+ final stickyHeaderHeight = StickyDateHeader.heightOf(context);
+ final rawTranslateY = nextHeaderTop == null
+ ? 0.0
+ : min(0.0, nextHeaderTop - stickyTop - stickyHeaderHeight - 5);
+ final translateY = rawTranslateY
+ .clamp(-(stickyHeaderHeight + 5), 0.0)
+ .toDouble();
+ setStickyDateHeader(
+ StickyDateHeaderState(
+ label: formatDayHeading(activeDayTimestamp),
+ translateY: (translateY * 2).round() / 2,
+ ),
+ activeDayTimestamp: activeDayTimestamp,
+ );
+ }
+
Future performLatestNavigation() async {
if (!context.mounted || !itemScrollController.isAttached) {
isAutoScrolling.value = false;
@@ -192,6 +340,7 @@ class _MessageList extends HookConsumerWidget {
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
+ isJumpToLatestVisible.value = false;
}
} finally {
isAutoScrolling.value = false;
@@ -203,6 +352,7 @@ class _MessageList extends HookConsumerWidget {
isAutoScrolling.value = true;
followsLatest.value = true;
hasUserScrolled.value = false;
+ hasUnseenLatestEntry.value = false;
latestNavigationRequest.value += 1;
}
@@ -253,6 +403,36 @@ class _MessageList extends HookConsumerWidget {
);
}
+ void updateJumpToLatestVisibility(
+ Iterable positions, {
+ double? viewportDimension,
+ }) {
+ final latestIsVisible = positions.any(
+ (position) =>
+ position.index == 0 &&
+ position.itemLeadingEdge < 1 &&
+ position.itemTrailingEdge > latestAlignment(),
+ );
+ final viewportHeight = viewportDimension ?? timelineViewportHeight.value;
+ final visiblePageHeight = max(
+ 0.0,
+ viewportHeight -
+ frostedAppBarHeight(
+ context,
+ titleContentHeight: appBarTitleContentHeight,
+ ) -
+ composerBottomInset,
+ );
+ final shouldShow =
+ !latestIsAtBoundary() &&
+ (hasUnseenLatestEntry.value ||
+ !latestIsVisible ||
+ distanceFromLatest.value > visiblePageHeight);
+ if (isJumpToLatestVisible.value != shouldShow) {
+ isJumpToLatestVisible.value = shouldShow;
+ }
+ }
+
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
isAutoScrolling.value ||
@@ -273,48 +453,78 @@ class _MessageList extends HookConsumerWidget {
}
// A dock or keyboard resize is a layout correction, not a navigation
// action. Keeping it instant avoids restarting a smooth scroll for
- // every position report while the viewport settles. The rebuilt list
- // padding already owns the composer/IME offset; the default alignment
- // also keeps short timelines flush with that padding.
+ // every position report while the viewport settles.
itemScrollController.jumpTo(index: 0);
});
}
- useEffect(() {
- void onPositionsChanged() {
- final positions = itemPositionsListener.itemPositions.value;
- if (positions.isEmpty) return;
- final nextIsAtLatest = latestIsAtBoundary();
- if (showUnreadNavigation &&
- nextIsAtLatest &&
- detachedWhileUnreadShown.value) {
- isUnreadNavigationDismissed.value = true;
- }
- if (nextIsAtLatest) {
- if (!isAtLatest.value) isAtLatest.value = true;
- } else if (!followsLatest.value && isAtLatest.value) {
- isAtLatest.value = false;
- }
+ useEffect(
+ () {
+ void onPositionsChanged() {
+ final positions = itemPositionsListener.itemPositions.value;
+ if (positions.isEmpty) return;
+ updateStickyDateHeader(positions);
+ updateJumpToLatestVisibility(positions);
+ final nextIsAtLatest = latestIsAtBoundary();
+ if (showUnreadNavigation &&
+ nextIsAtLatest &&
+ detachedWhileUnreadShown.value) {
+ isUnreadNavigationDismissed.value = true;
+ }
+ if (nextIsAtLatest) {
+ hasUnseenLatestEntry.value = false;
+ if (!isAtLatest.value) isAtLatest.value = true;
+ if (isJumpToLatestVisible.value) {
+ isJumpToLatestVisible.value = false;
+ }
+ } else if (!followsLatest.value && isAtLatest.value) {
+ isAtLatest.value = false;
+ }
- final oldestVisible = positions
- .map((position) => position.index)
- .reduce((a, b) => a > b ? a : b);
- if (!hasUserScrolled.value ||
- oldestVisible < displayEntries.length - 3 ||
- isLoadingOlder.value) {
- return;
+ final oldestVisible = positions
+ .map((position) => position.index)
+ .reduce((a, b) => a > b ? a : b);
+ if (!hasUserScrolled.value ||
+ oldestVisible < displayEntries.length - 3 ||
+ isLoadingOlder.value) {
+ return;
+ }
+ final notifier = ref.read(
+ channelMessagesProvider(channelId).notifier,
+ );
+ if (notifier.reachedOldest) return;
+ isLoadingOlder.value = true;
+ notifier.fetchOlder().whenComplete(
+ () => isLoadingOlder.value = false,
+ );
}
- final notifier = ref.read(channelMessagesProvider(channelId).notifier);
- if (notifier.reachedOldest) return;
- isLoadingOlder.value = true;
- notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
- }
- itemPositionsListener.itemPositions.addListener(onPositionsChanged);
- return () => itemPositionsListener.itemPositions.removeListener(
- onPositionsChanged,
- );
- }, [channelId, entries.length, itemPositionsListener]);
+ var disposed = false;
+ itemPositionsListener.itemPositions.addListener(onPositionsChanged);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!disposed && context.mounted) onPositionsChanged();
+ });
+ return () {
+ disposed = true;
+ itemPositionsListener.itemPositions.removeListener(
+ onPositionsChanged,
+ );
+ };
+ },
+ [
+ channelId,
+ entries.length,
+ itemPositionsListener,
+ appBarTitleContentHeight,
+ composerBottomInset,
+ ],
+ );
+
+ useEffect(() {
+ stickyDateHeaderState.value = StickyDateHeaderState.hidden;
+ stickyDayTimestamp.value = null;
+ return null;
+ }, [channelId]);
// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
@@ -396,12 +606,25 @@ class _MessageList extends HookConsumerWidget {
previousLatestEntryId.value = latestEntryId;
if (previous == null ||
latestEntryId == null ||
- previous == latestEntryId ||
- !isAtLatest.value) {
+ previous == latestEntryId) {
return null;
}
+ if (!followsLatest.value || hasUserScrolled.value) {
+ hasUnseenLatestEntry.value = true;
+ }
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (context.mounted) scrollToLatest();
+ if (!context.mounted) return;
+ if (followsLatest.value && !hasUserScrolled.value) {
+ scrollToLatest();
+ return;
+ }
+ final positions = itemPositionsListener.itemPositions.value;
+ if (positions.isNotEmpty) {
+ if (latestIsAtBoundary()) {
+ hasUnseenLatestEntry.value = false;
+ }
+ updateJumpToLatestVisibility(positions);
+ }
});
return null;
}, [latestEntryId]);
@@ -446,8 +669,32 @@ class _MessageList extends HookConsumerWidget {
return Stack(
children: [
- NotificationListener(
+ NotificationListener(
onNotification: (notification) {
+ if (notification is ScrollMetricsNotification &&
+ notification.depth != 0) {
+ return false;
+ }
+ if (notification is ScrollNotification && notification.depth != 0) {
+ return false;
+ }
+ if (notification is ScrollMetricsNotification) {
+ timelineViewportHeight.value =
+ notification.metrics.viewportDimension;
+ return false;
+ }
+ if (notification is! ScrollNotification) return false;
+ timelineViewportHeight.value =
+ notification.metrics.viewportDimension;
+ distanceFromLatest.value = max(
+ 0.0,
+ notification.metrics.pixels -
+ notification.metrics.minScrollExtent,
+ );
+ updateJumpToLatestVisibility(
+ itemPositionsListener.itemPositions.value,
+ viewportDimension: notification.metrics.viewportDimension,
+ );
if (notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle) {
hasUserScrolled.value = true;
@@ -528,7 +775,11 @@ class _MessageList extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
- DayDivider(label: formatDayHeading(message.createdAt)),
+ DayDivider(
+ label: formatDayHeading(message.createdAt),
+ dayTimestamp: message.createdAt,
+ stickyDayTimestamp: stickyDayTimestamp,
+ ),
if (message.isSystem)
_SystemMessageRow(
message: message,
@@ -572,6 +823,21 @@ class _MessageList extends HookConsumerWidget {
),
),
),
+ if (!showUnreadNavigation)
+ Positioned(
+ left: 0,
+ right: 0,
+ top:
+ frostedAppBarHeight(
+ context,
+ titleContentHeight: appBarTitleContentHeight,
+ ) +
+ Grid.twelve,
+ child: StickyDateHeader(
+ key: const ValueKey('channel-sticky-date-header'),
+ state: stickyDateHeaderState,
+ ),
+ ),
if (showUnreadNavigation)
Positioned(
left: 0,
@@ -595,16 +861,38 @@ class _MessageList extends HookConsumerWidget {
),
),
)
- else if (!isAtLatest.value)
+ else
Positioned(
left: 0,
right: 0,
bottom: navigationBottomInset + Grid.xs,
child: Center(
- child: LatestMessageButton(
- key: const ValueKey('channel-jump-to-latest'),
- surfaceKey: const ValueKey('channel-jump-to-latest-surface'),
- onPressed: scrollToLatest,
+ child: AnimatedSwitcher(
+ key: const ValueKey('channel-jump-to-latest-switcher'),
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 180),
+ reverseDuration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 160),
+ switchInCurve: Curves.easeOutCubic,
+ switchOutCurve: Curves.easeInCubic,
+ transitionBuilder: (child, animation) => FadeTransition(
+ opacity: animation,
+ child: ScaleTransition(
+ scale: _JumpToLatestScaleAnimation(animation),
+ alignment: Alignment.bottomCenter,
+ child: child,
+ ),
+ ),
+ child: !isJumpToLatestVisible.value
+ ? const SizedBox.shrink(
+ key: ValueKey('channel-jump-to-latest-hidden'),
+ )
+ : JumpToLatestButton(
+ key: const ValueKey('channel-jump-to-latest'),
+ onPressed: scrollToLatest,
+ ),
),
),
),
@@ -612,3 +900,16 @@ class _MessageList extends HookConsumerWidget {
);
}
}
+
+class _JumpToLatestScaleAnimation extends Animation
+ with AnimationWithParentMixin {
+ @override
+ final Animation parent;
+
+ _JumpToLatestScaleAnimation(this.parent);
+
+ @override
+ double get value => parent.status == AnimationStatus.reverse
+ ? parent.value
+ : 0.92 + (0.08 * parent.value);
+}
diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart
index 7d65ad58fe6..24f5bb798e3 100644
--- a/mobile/lib/features/channels/day_divider.dart
+++ b/mobile/lib/features/channels/day_divider.dart
@@ -1,53 +1,60 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
-/// Desktop-parity day separator with a centered label over a horizontal rule.
+/// In-flow date label. The active date gains a glass capsule when it sticks.
class DayDivider extends StatelessWidget {
final String label;
+ final int? dayTimestamp;
+ final ValueListenable? stickyDayTimestamp;
- const DayDivider({super.key, required this.label});
+ const DayDivider({
+ super.key,
+ required this.label,
+ this.dayTimestamp,
+ this.stickyDayTimestamp,
+ });
+
+ Widget _buildOpacity(BuildContext context, {required bool isSticky}) {
+ return ExcludeSemantics(
+ excluding: isSticky,
+ child: AnimatedOpacity(
+ key: dayTimestamp == null
+ ? null
+ : ValueKey('channel-day-divider-opacity-$dayTimestamp'),
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 120),
+ curve: Curves.easeOutCubic,
+ opacity: isSticky ? 0 : 1,
+ child: Text(
+ label,
+ style: context.textTheme.labelSmall?.copyWith(
+ color: context.colors.onSurfaceVariant.withValues(alpha: 0.72),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ );
+ }
@override
Widget build(BuildContext context) {
+ final activeTimestamp = stickyDayTimestamp;
+ final timestamp = dayTimestamp;
return Padding(
- padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
- child: SizedBox(
- width: double.infinity,
- child: Stack(
- alignment: Alignment.center,
- children: [
- Positioned(
- left: 0,
- right: 0,
- child: Divider(
- height: 1,
- thickness: 1,
- color: context.colors.outlineVariant.withValues(alpha: 0.35),
- ),
- ),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: Grid.xxs + Grid.quarter,
- vertical: Grid.half,
- ),
- decoration: BoxDecoration(
- color: context.colors.surface,
- borderRadius: BorderRadius.circular(Radii.dialog),
- border: Border.all(
- color: context.colors.outlineVariant.withValues(alpha: 0.7),
+ padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter),
+ child: Center(
+ child: activeTimestamp == null || timestamp == null
+ ? _buildOpacity(context, isSticky: false)
+ : ValueListenableBuilder(
+ valueListenable: activeTimestamp,
+ builder: (context, activeDayTimestamp, _) => _buildOpacity(
+ context,
+ isSticky: activeDayTimestamp == timestamp,
),
),
- child: Text(
- label,
- style: context.textTheme.labelSmall?.copyWith(
- color: context.colors.onSurfaceVariant.withValues(alpha: 0.7),
- letterSpacing: 0.22,
- ),
- ),
- ),
- ],
- ),
),
);
}
diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart
new file mode 100644
index 00000000000..cb6b703a353
--- /dev/null
+++ b/mobile/lib/features/channels/jump_to_latest_button.dart
@@ -0,0 +1,112 @@
+import 'dart:async';
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.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';
+
+import '../../shared/theme/theme.dart';
+
+/// Compact conversation control that returns a detached timeline to its tail.
+class JumpToLatestButton extends HookConsumerWidget {
+ final VoidCallback onPressed;
+
+ const JumpToLatestButton({required this.onPressed, super.key});
+
+ static const _iosViewType = 'buzz/jump_to_latest_glass';
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final nativeChannel = useState(null);
+ final onPressedRef = useRef(onPressed)..value = onPressed;
+ final brightness = context.theme.brightness.name;
+
+ useEffect(() {
+ final channel = nativeChannel.value;
+ if (channel == null) return null;
+ channel.setMethodCallHandler((call) async {
+ if (call.method == 'pressed') onPressedRef.value();
+ });
+ return () => channel.setMethodCallHandler(null);
+ }, [nativeChannel.value]);
+
+ useEffect(() {
+ final channel = nativeChannel.value;
+ if (channel != null) {
+ unawaited(channel.invokeMethod('setBrightness', brightness));
+ }
+ return null;
+ }, [nativeChannel.value, brightness]);
+
+ final borderColor = context.colors.onSurface.withValues(alpha: 0.08);
+ final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS;
+
+ return Semantics(
+ button: true,
+ label: 'Jump to latest message',
+ child: Tooltip(
+ excludeFromSemantics: true,
+ message: 'Jump to latest message',
+ child: SizedBox.square(
+ dimension: Grid.xl,
+ child: usesNativeIosGlass
+ ? UiKitView(
+ key: const ValueKey('channel-jump-to-latest-ios-glass'),
+ viewType: _iosViewType,
+ hitTestBehavior: PlatformViewHitTestBehavior.opaque,
+ creationParams: {'brightness': brightness},
+ creationParamsCodec: const StandardMessageCodec(),
+ onPlatformViewCreated: (viewId) {
+ nativeChannel.value = MethodChannel(
+ '$_iosViewType/$viewId',
+ );
+ },
+ )
+ : Material(
+ color: Colors.transparent,
+ child: InkResponse(
+ containedInkWell: true,
+ customBorder: const CircleBorder(),
+ onTap: onPressed,
+ radius: Grid.sm,
+ child: Align(
+ key: const ValueKey(
+ 'channel-jump-to-latest-visual-anchor',
+ ),
+ alignment: Alignment.bottomCenter,
+ child: ClipOval(
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
+ child: Container(
+ key: const ValueKey(
+ 'channel-jump-to-latest-surface',
+ ),
+ width: Grid.lg,
+ height: Grid.lg,
+ decoration: BoxDecoration(
+ color: context.colors.surface.withValues(
+ alpha: 0.72,
+ ),
+ shape: BoxShape.circle,
+ border: Border.all(color: borderColor),
+ ),
+ child: Icon(
+ LucideIcons.arrowDown,
+ size: Grid.gutter,
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart
new file mode 100644
index 00000000000..f6d17d2b9b2
--- /dev/null
+++ b/mobile/lib/features/channels/sticky_date_header.dart
@@ -0,0 +1,210 @@
+import 'dart:async';
+import 'dart:math';
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+import '../../shared/theme/theme.dart';
+
+/// The active date and vertical push-off applied to a sticky date header.
+@immutable
+class StickyDateHeaderState {
+ final String? label;
+ final double translateY;
+
+ const StickyDateHeaderState({this.label, this.translateY = 0});
+
+ static const hidden = StickyDateHeaderState();
+
+ bool get isVisible => label != null;
+
+ @override
+ bool operator ==(Object other) {
+ return other is StickyDateHeaderState &&
+ other.label == label &&
+ other.translateY == translateY;
+ }
+
+ @override
+ int get hashCode => Object.hash(label, translateY);
+}
+
+/// A glass date capsule that remains below the app bar as its day scrolls.
+class StickyDateHeader extends StatelessWidget {
+ final ValueListenable state;
+
+ const StickyDateHeader({required this.state, super.key});
+
+ static const _iosViewType = 'buzz/sticky_date_glass';
+ static const _minimumIosGlassHeight = 28.0;
+
+ /// Height used by the surface and the next-day push-off calculation.
+ static double heightOf(BuildContext context) {
+ final labelStyle = context.textTheme.labelMedium;
+ final unscaledLineHeight =
+ (labelStyle?.fontSize ?? 14) * (labelStyle?.height ?? 1.25);
+ final contentHeight =
+ MediaQuery.textScalerOf(context).scale(unscaledLineHeight) + Grid.xxs;
+ return defaultTargetPlatform == TargetPlatform.iOS
+ ? max(_minimumIosGlassHeight, contentHeight)
+ : contentHeight;
+ }
+
+ Widget _buildIosGlass(BuildContext context, String label) {
+ final textStyle = context.textTheme.labelMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ fontWeight: FontWeight.w500,
+ );
+ final textPainter = TextPainter(
+ text: TextSpan(text: label, style: textStyle),
+ maxLines: 1,
+ textDirection: Directionality.of(context),
+ textScaler: MediaQuery.textScalerOf(context),
+ )..layout();
+
+ return _IosStickyDateGlass(
+ label: label,
+ width: textPainter.width + Grid.sm,
+ height: heightOf(context),
+ );
+ }
+
+ Widget _buildFlutterSurface(BuildContext context, String label) {
+ return Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(Radii.full),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.12),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(Radii.full),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
+ child: Container(
+ key: const ValueKey('channel-sticky-date-header-surface'),
+ padding: const EdgeInsets.symmetric(
+ horizontal: Grid.twelve,
+ vertical: Grid.half,
+ ),
+ decoration: BoxDecoration(
+ color: context.colors.surface.withValues(alpha: 0.82),
+ borderRadius: BorderRadius.circular(Radii.full),
+ border: Border.all(
+ color: context.colors.onSurface.withValues(alpha: 0.08),
+ ),
+ ),
+ child: Semantics(
+ header: true,
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.labelMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final reducedMotion = MediaQuery.disableAnimationsOf(context);
+
+ return ValueListenableBuilder(
+ valueListenable: state,
+ builder: (context, value, _) {
+ return IgnorePointer(
+ child: ExcludeSemantics(
+ excluding: !value.isVisible,
+ child: AnimatedOpacity(
+ duration: reducedMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 120),
+ curve: Curves.easeOutCubic,
+ opacity: value.isVisible ? 1 : 0,
+ child: Transform.translate(
+ offset: Offset(0, value.translateY),
+ child: Center(
+ child: RepaintBoundary(
+ child: defaultTargetPlatform == TargetPlatform.iOS
+ ? _buildIosGlass(context, value.label ?? '')
+ : _buildFlutterSurface(context, value.label ?? ''),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _IosStickyDateGlass extends HookConsumerWidget {
+ final String label;
+ final double width;
+ final double height;
+
+ const _IosStickyDateGlass({
+ required this.label,
+ required this.width,
+ required this.height,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final nativeChannel = useState(null);
+ final brightness = context.theme.brightness.name;
+
+ useEffect(() {
+ final channel = nativeChannel.value;
+ if (channel != null) {
+ unawaited(channel.invokeMethod('setLabel', label));
+ unawaited(channel.invokeMethod('setBrightness', brightness));
+ }
+ return null;
+ }, [nativeChannel.value, label, brightness]);
+
+ return Semantics(
+ header: true,
+ label: label,
+ child: ExcludeSemantics(
+ child: SizedBox(
+ key: const ValueKey('channel-sticky-date-header-surface'),
+ width: width,
+ height: height,
+ child: UiKitView(
+ key: const ValueKey('channel-sticky-date-header-ios-glass'),
+ viewType: StickyDateHeader._iosViewType,
+ hitTestBehavior: PlatformViewHitTestBehavior.transparent,
+ creationParams: {
+ 'label': label,
+ 'brightness': brightness,
+ },
+ creationParamsCodec: const StandardMessageCodec(),
+ onPlatformViewCreated: (viewId) {
+ nativeChannel.value = MethodChannel(
+ '${StickyDateHeader._iosViewType}/$viewId',
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 79e385452bb..9b9ac596c7f 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -1717,8 +1717,9 @@ void main() {
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
- expect(find.text('Latest'), findsOneWidget);
+ expect(find.text('Latest'), findsNothing);
expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget);
+ expect(find.byTooltip('Jump to latest message'), findsOneWidget);
});
testWidgets('loads history through the oldest unread boundary', (
@@ -2515,19 +2516,83 @@ void main() {
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
- final latestSurface = tester.widget(
- find.byKey(const ValueKey('channel-jump-to-latest-surface')),
+ final latestSurfaceFinder = find.byKey(
+ const ValueKey('channel-jump-to-latest-surface'),
);
+ final latestSurface = tester.widget(latestSurfaceFinder);
final latestDecoration = latestSurface.decoration! as BoxDecoration;
- expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full));
+ expect(latestDecoration.shape, BoxShape.circle);
expect(
latestDecoration.color,
- AppTheme.light().colorScheme.surface.withValues(alpha: 0.5),
+ AppTheme.light().colorScheme.surface.withValues(alpha: 0.72),
);
expect(
(latestDecoration.border! as Border).top.color,
- Colors.black.withValues(alpha: 0.04),
+ AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.08),
+ );
+ expect(
+ tester.getSize(find.byKey(const ValueKey('channel-jump-to-latest'))),
+ const Size.square(Grid.xl),
+ );
+ expect(
+ tester
+ .getCenter(find.byKey(const ValueKey('channel-jump-to-latest')))
+ .dx,
+ closeTo(tester.getCenter(messageList).dx, 0.1),
+ );
+ expect(
+ tester
+ .getTopLeft(find.byKey(const ValueKey('channel-composer-dock')))
+ .dy -
+ tester
+ .getBottomRight(
+ find.byKey(const ValueKey('channel-jump-to-latest')),
+ )
+ .dy,
+ closeTo(Grid.xs, 0.1),
+ );
+ final latestSwitcher = tester.widget(
+ find.byKey(const ValueKey('channel-jump-to-latest-switcher')),
+ );
+ expect(latestSwitcher.duration, const Duration(milliseconds: 180));
+ expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 160));
+ expect(latestSwitcher.switchInCurve, Curves.easeOutCubic);
+ expect(latestSwitcher.switchOutCurve, Curves.easeInCubic);
+ final latestScaleTransition = tester.widget(
+ find.descendant(
+ of: find.byKey(const ValueKey('channel-jump-to-latest-switcher')),
+ matching: find.byType(ScaleTransition),
+ ),
+ );
+ expect(latestScaleTransition.alignment, Alignment.bottomCenter);
+ final visualAnchor = tester.widget(
+ find.byKey(const ValueKey('channel-jump-to-latest-visual-anchor')),
+ );
+ expect(visualAnchor.alignment, Alignment.bottomCenter);
+ expect(tester.getSize(latestSurfaceFinder), const Size.square(Grid.lg));
+ expect(
+ tester.getBottomRight(latestSurfaceFinder).dy,
+ closeTo(
+ tester
+ .getBottomRight(
+ find.byKey(const ValueKey('channel-jump-to-latest')),
+ )
+ .dy,
+ 0.1,
+ ),
);
+ expect(find.text('Latest'), findsNothing);
+ expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget);
+ for (final container in tester.widgetList(
+ find.descendant(
+ of: find.byKey(const ValueKey('channel-jump-to-latest')),
+ matching: find.byType(Container),
+ ),
+ )) {
+ if (container.decoration case final BoxDecoration decoration) {
+ expect(decoration.boxShadow, anyOf(isNull, isEmpty));
+ }
+ }
expect(
find.descendant(
of: find.byKey(const ValueKey('channel-jump-to-latest')),
@@ -2549,6 +2614,30 @@ void main() {
expect(findRichText('Newest live update'), findsNothing);
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
+ await tester.pump();
+
+ ScaleTransition exitingScaleTransition() {
+ return tester.widget(
+ find.ancestor(
+ of: find.byKey(const ValueKey('channel-jump-to-latest')),
+ matching: find.byType(ScaleTransition),
+ ),
+ );
+ }
+
+ for (var frame = 0; frame < 60; frame += 1) {
+ await tester.pump(const Duration(milliseconds: 16));
+ if (exitingScaleTransition().scale.status == AnimationStatus.reverse) {
+ break;
+ }
+ }
+ expect(exitingScaleTransition().scale.status, AnimationStatus.reverse);
+ await tester.pump(const Duration(milliseconds: 120));
+
+ final collapsedScaleTransition = exitingScaleTransition();
+ expect(collapsedScaleTransition.alignment, Alignment.bottomCenter);
+ expect(collapsedScaleTransition.scale.value, lessThan(0.1));
+
await tester.pumpAndSettle();
expect(findRichText('Newest live update'), findsOneWidget);
@@ -2742,6 +2831,74 @@ void main() {
},
);
+ testWidgets(
+ 'pins the current day below the app bar after its divider scrolls away',
+ (tester) async {
+ tester.view.physicalSize = const Size(400, 600);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+
+ final firstDay =
+ DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
+ final messages = [
+ for (var day = 0; day < 3; day += 1)
+ for (var index = 0; index < 10; index += 1)
+ _textMsg(
+ id: 'day-$day-message-$index',
+ pubkey: 'alice',
+ content: 'Day $day message $index',
+ createdAt: firstDay + day * 86400 + index,
+ ),
+ ];
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: messages,
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ },
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final messageList = find.byKey(const ValueKey('channel-message-list'));
+ final list = tester.widget(messageList);
+ list.itemScrollController!.jumpTo(index: 14, alignment: 0.8);
+ await tester.pumpAndSettle();
+
+ final stickyHeader = find.byKey(
+ const ValueKey('channel-sticky-date-header'),
+ );
+ final stickySurface = find.byKey(
+ const ValueKey('channel-sticky-date-header-surface'),
+ );
+ expect(stickyHeader, findsOneWidget);
+ expect(stickySurface, findsOneWidget);
+ expect(
+ find.descendant(
+ of: stickyHeader,
+ matching: find.text(formatDayHeading(firstDay + 86400)),
+ ),
+ findsOneWidget,
+ );
+ expect(
+ tester.getTopLeft(stickySurface).dy,
+ closeTo(
+ frostedAppBarHeight(tester.element(stickyHeader)) + Grid.twelve,
+ 1,
+ ),
+ );
+ expect(
+ find.descendant(
+ of: stickyHeader,
+ matching: find.byType(BackdropFilter),
+ ),
+ findsOneWidget,
+ );
+ },
+ );
+
testWidgets(
'keeps follow mode off while a tall newest message stays visible',
(tester) async {
@@ -2790,7 +2947,7 @@ void main() {
expect(findRichText('Newest message line 0'), findsOneWidget);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
- findsOneWidget,
+ findsNothing,
);
messagesNotifier.setMessages([
diff --git a/mobile/test/features/channels/day_divider_test.dart b/mobile/test/features/channels/day_divider_test.dart
new file mode 100644
index 00000000000..23b39052681
--- /dev/null
+++ b/mobile/test/features/channels/day_divider_test.dart
@@ -0,0 +1,44 @@
+import 'package:buzz/features/channels/day_divider.dart';
+import 'package:buzz/shared/theme/theme.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('fades the in-flow date while that day is sticky', (
+ tester,
+ ) async {
+ final stickyDayTimestamp = ValueNotifier(null);
+ addTearDown(stickyDayTimestamp.dispose);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(
+ body: DayDivider(
+ label: 'Today',
+ dayTimestamp: 1000,
+ stickyDayTimestamp: stickyDayTimestamp,
+ ),
+ ),
+ ),
+ );
+
+ AnimatedOpacity opacity() => tester.widget(
+ find.byKey(const ValueKey('channel-day-divider-opacity-1000')),
+ );
+
+ expect(opacity().opacity, 1);
+
+ stickyDayTimestamp.value = 1000;
+ await tester.pump();
+
+ expect(opacity().opacity, 0);
+ expect(opacity().duration, const Duration(milliseconds: 120));
+ expect(opacity().curve, Curves.easeOutCubic);
+
+ stickyDayTimestamp.value = null;
+ await tester.pump();
+
+ expect(opacity().opacity, 1);
+ });
+}
diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart
new file mode 100644
index 00000000000..26b50455859
--- /dev/null
+++ b/mobile/test/features/channels/jump_to_latest_button_test.dart
@@ -0,0 +1,76 @@
+import 'package:buzz/features/channels/jump_to_latest_button.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';
+
+void main() {
+ testWidgets('keeps the native iOS glass in sync with the app theme', (
+ tester,
+ ) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ const channel = MethodChannel('buzz/jump_to_latest_glass/41');
+ final methodCalls = [];
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
+ call,
+ ) async {
+ methodCalls.add(call);
+ return null;
+ });
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(body: JumpToLatestButton(onPressed: () {})),
+ ),
+ ),
+ );
+
+ final nativeView = tester.widget(find.byType(UiKitView));
+ expect(nativeView.viewType, 'buzz/jump_to_latest_glass');
+ expect(nativeView.creationParams, {
+ 'brightness': 'light',
+ });
+ expect(
+ find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')),
+ findsOneWidget,
+ );
+
+ nativeView.onPlatformViewCreated!(41);
+ await tester.pump();
+ expect(
+ methodCalls
+ .lastWhere((call) => call.method == 'setBrightness')
+ .arguments,
+ 'light',
+ );
+
+ methodCalls.clear();
+ await tester.pumpWidget(
+ ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.dark(),
+ home: Scaffold(body: JumpToLatestButton(onPressed: () {})),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(
+ methodCalls
+ .lastWhere((call) => call.method == 'setBrightness')
+ .arguments,
+ 'dark',
+ );
+ } finally {
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
+ channel,
+ null,
+ );
+ debugDefaultTargetPlatformOverride = null;
+ }
+ });
+}
diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart
new file mode 100644
index 00000000000..5f89ebb8106
--- /dev/null
+++ b/mobile/test/features/channels/sticky_date_header_test.dart
@@ -0,0 +1,117 @@
+import 'package:buzz/features/channels/sticky_date_header.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';
+
+void main() {
+ testWidgets('updates the native iOS glass date and app theme', (
+ tester,
+ ) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ final state = ValueNotifier(
+ const StickyDateHeaderState(label: 'Yesterday'),
+ );
+ const channel = MethodChannel('buzz/sticky_date_glass/42');
+ final methodCalls = [];
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
+ call,
+ ) async {
+ methodCalls.add(call);
+ return null;
+ });
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(body: StickyDateHeader(state: state)),
+ ),
+ ),
+ );
+
+ var nativeView = tester.widget(find.byType(UiKitView));
+ expect(nativeView.viewType, 'buzz/sticky_date_glass');
+ expect(nativeView.creationParams, {
+ 'label': 'Yesterday',
+ 'brightness': 'light',
+ });
+ expect(find.byType(BackdropFilter), findsNothing);
+
+ nativeView.onPlatformViewCreated!(42);
+ await tester.pump();
+ expect(
+ methodCalls.lastWhere((call) => call.method == 'setLabel').arguments,
+ 'Yesterday',
+ );
+ expect(
+ methodCalls
+ .lastWhere((call) => call.method == 'setBrightness')
+ .arguments,
+ 'light',
+ );
+
+ state.value = const StickyDateHeaderState(label: 'Today');
+ await tester.pump();
+
+ nativeView = tester.widget(find.byType(UiKitView));
+ expect(nativeView.creationParams, {
+ 'label': 'Today',
+ 'brightness': 'light',
+ });
+ expect(
+ methodCalls.lastWhere((call) => call.method == 'setLabel').arguments,
+ 'Today',
+ );
+
+ methodCalls.clear();
+ await tester.pumpWidget(
+ ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.dark(),
+ home: Scaffold(body: StickyDateHeader(state: state)),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(
+ methodCalls
+ .lastWhere((call) => call.method == 'setBrightness')
+ .arguments,
+ 'dark',
+ );
+ } finally {
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
+ channel,
+ null,
+ );
+ state.dispose();
+ debugDefaultTargetPlatformOverride = null;
+ }
+ });
+
+ testWidgets('keeps the Flutter date surface on Android', (tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ final state = ValueNotifier(const StickyDateHeaderState(label: 'Today'));
+ try {
+ await tester.pumpWidget(
+ ProviderScope(
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: Scaffold(body: StickyDateHeader(state: state)),
+ ),
+ ),
+ );
+
+ expect(find.byType(UiKitView), findsNothing);
+ expect(find.byType(BackdropFilter), findsOneWidget);
+ expect(find.text('Today'), findsOneWidget);
+ } finally {
+ state.dispose();
+ debugDefaultTargetPlatformOverride = null;
+ }
+ });
+}
From c442a90a176845e3989436f2bb24eb6d0ca79d47 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Tue, 18 Aug 2026 12:54:40 -0700
Subject: [PATCH 12/27] fix(desktop-chrome): preserve balanced layout when
sidebar collapses (#6000)
**Category:** fix
**User Impact:** The desktop content surface now keeps balanced chrome
spacing when the sidebar is collapsed or multiple communities are
visible, with a cleaner sidebar transition.
**Problem:** Collapsing the left sidebar removed the content surface's
left inset, while multi-community layouts also exposed uneven visible
gutters and sidebar content during the exit transition. **Solution:**
Preserve an 8px visible gutter around the content surface, clip and fade
only the sidebar's inner content while it trails off canvas, and leave
the opaque shell in place until the existing visibility transition
completes.
File changes
**desktop/src/app/BuzzThemeSurfaces.tsx**
Preserves a balanced visible left gutter after accounting for the
content-edge shadow.
**desktop/src/shared/ui/sidebar.tsx**
Clips the collapsing sidebar, disables interaction off canvas, and
fades/translates its inner content without exposing a different
background.
**desktop/tests/e2e/community-rail.spec.ts**
Adds gutter assertions for single-community collapsed-sidebar and
multi-community layouts.
**desktop/tests/e2e/sidebar.spec.ts**
Covers the collapse opacity, translation, clipping, pointer-event
behavior, and restoration on reopen.
## Reproduction steps
1. Launch the desktop app with the Buzz theme and at least two
communities.
2. Collapse the channel sidebar.
3. Confirm the main content surface keeps equal visible left and right
gutters and does not overlap the community rail.
4. Reopen and collapse the sidebar again; confirm its contents fade and
trail right while the chrome background stays opaque, then restore fully
when reopened.
## Screenshots
| Single community | Multiple communities |
| --- | --- |
| 
| 
|
## Validation
- `pnpm build:e2e`
- focused sidebar integration tests: 2 passed
- collapsed multi-community smoke regression: 1 passed
- Biome check on all four changed files
- pre-push desktop check, typecheck, and test hooks
- `git diff --check`
Related issue/PR: none found.
---------
Signed-off-by: Taylor Ho
---
desktop/src/app/AppShell.tsx | 1 +
desktop/src/app/AppShellChannelSurface.tsx | 17 +++-
.../src/features/sidebar/ui/CommunityRail.tsx | 2 +-
desktop/src/shared/ui/sidebar.tsx | 26 +++---
desktop/tests/e2e/community-rail.spec.ts | 75 +++++++++++++++-
.../tests/e2e/sidebar-offcanvas-rail.spec.ts | 88 ++++++++++++++++++-
desktop/tests/e2e/sidebar.spec.ts | 69 +++++++++++++++
7 files changed, 259 insertions(+), 19 deletions(-)
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 6257a75b720..96aa7acbac8 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/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx
index 5394065b19c..12cb2ea9d39 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 (
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,
@@ -366,7 +362,7 @@ const Sidebar = React.forwardRef<
@@ -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/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index 3f6d38602ab..8d24ef7e1df 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 ({
@@ -1133,7 +1154,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 +1192,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 +1243,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/sidebar-offcanvas-rail.spec.ts b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts
index 8bee8da6592..df94afc6998 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 7419499baf8..01d9d64d67b 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" });
From d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Tue, 18 Aug 2026 12:55:00 -0700
Subject: [PATCH 13/27] fix(shared-ui): delay hover disclosures by default
(#5821)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Category:** fix
**User Impact:** Hover tooltips and informational popovers now wait for
deliberate pointer dwell instead of appearing while users move around
the app.
**Problem:** Tooltips and hover-controlled popovers appeared after
inconsistent, often very short delays, so moving across composer and
navigation controls could obstruct the next interaction.
**Solution:** Establish a 500 ms shared dwell default with no tooltip
skip-delay cascade, apply it to informational hover-controlled Popovers,
and preserve immediate click and keyboard behavior. The responsive
Community actions navigation submenu retains its documented 80 ms open /
160 ms close timing.
## Before / after
| Before | After |
|---|---|
| 
| 
|
File changes
**desktop/src/shared/ui/tooltip.tsx**
Wraps the Radix provider with documented 500 ms and zero skip-delay
defaults. The provider API still permits a future proven exception, but
no current `desktop/src` caller overrides either timing.
**desktop/src/shared/ui/popover.tsx**
Exports the documented shared hover-open timing for controlled popovers;
ordinary click/focus Popovers remain immediate.
**desktop/src/main.tsx**
Uses the shared Tooltip provider defaults at the application root.
**desktop/src/shared/ui/sidebar.tsx**
Removes the sidebar's instant Tooltip timing override.
**desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx**
Removes the local short Tooltip timing override.
**desktop/src/features/home/ui/InboxDetailPane.tsx**
Removes the local short Tooltip timing override.
**desktop/src/features/messages/ui/MessageTimeline.tsx**
Removes the timeline's local short Tooltip timing override.
**desktop/src/features/messages/ui/MessageTimestamp.tsx**
Drops the timestamp-only provider now that its 500 ms, zero-skip
behavior is shared globally.
**desktop/src/features/channels/ui/BotActivityBar.tsx**
Raises composer agent-activity hover dwell from 150 ms to the shared
default while preserving immediate click/focus opening.
**desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx**
Raises channel activity hover dwell from 250 ms to the shared default
while preserving immediate focus opening.
**desktop/src/features/profile/ui/UserProfilePopover.tsx**
Reuses the shared hover timing in place of its equivalent local
constant.
**desktop/src/shared/ui/PubKey.tsx**
Reuses the shared hover timing in place of its equivalent local
constant.
**desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx**
Raises emoji inspection hover dwell from 200 ms to the shared default
while preserving immediate focus opening.
**desktop/src/features/messages/ui/MessageReactions.tsx**
Raises reaction inspection hover dwell from 200 ms to the shared default
while preserving immediate focus and reaction clicks.
**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Documents the Community actions navigation submenu as an intentional
timing exception: 80 ms to open responsively and 160 ms to preserve the
pointer bridge into its portalled panel.
## Reproduction steps
Move the pointer rapidly across each surface first, then hold it still
over a labeled control. Hover-only disclosures should stay closed during
transit and open after about **500 ms** of deliberate dwell. Moving
directly between adjacent Tooltip triggers should start a fresh 500 ms
dwell rather than cascading the next Tooltip open immediately.
| Surface | Where to test | What to expect |
|---|---|---|
| Composer controls | Attachment, emoji, image editor, formatting, and
composer toolbar buttons | No Tooltip while sweeping across controls;
the hovered control's Tooltip opens after ~500 ms. Clicking remains
immediate. |
| Message actions | Hover a message, then test reply, react,
more-actions, edit, and related action-bar controls | Each Tooltip waits
~500 ms, including when moving between adjacent actions. The action
itself still runs immediately on click or keyboard activation. |
| Message metadata and content tools | Message timestamps, code-block
copy controls, diff controls, system-message controls, and video-player
controls | Tooltip appears after ~500 ms. Timestamp behavior should look
unchanged; it was already 500 ms with no skip cascade. |
| Reaction pills | Hover a reaction with one or more reactors; also
click the pill | Reactor Popover waits ~500 ms instead of 200 ms.
Clicking still toggles the reaction immediately. |
| Inline custom emoji | Hover a rendered custom emoji in message
Markdown, then focus it with the keyboard | Emoji inspector waits ~500
ms on hover instead of 200 ms. Keyboard focus opens it immediately. |
| Masked links | Hover a masked Markdown link, including one revealed
inside a spoiler | Destination Tooltip waits ~500 ms instead of the
former app-level 300 ms. Hidden spoilers still reveal no destination;
keyboard focus remains immediate. |
| Main and collapsed sidebar controls | Collapse the sidebar and hover
icon-only navigation/menu buttons; also test community-rail controls |
Sidebar Tooltips wait ~500 ms instead of opening instantly. Rapid
movement across icons should not produce a tooltip cascade. |
| Channel header and management controls | Channel members, huddle,
settings, thread-view mode, management rows, and quick-agent controls |
Each Tooltip waits ~500 ms; click/keyboard behavior remains immediate. |
| Add-channel team chips | Open Add channel where saved teams are
available and hover a team chip | Team details Tooltip waits ~500 ms
instead of 150 ms; clicking the chip still toggles the team immediately.
|
| Channel activity preview | Hover a sidebar channel that has activity,
then keyboard-focus its trigger | Activity Popover waits ~500 ms instead
of 250 ms. Focus opens it immediately, and quickly crossing channel rows
should not leave previews in the way. |
| Composer agent activity | Run an agent so the composer activity
control is present; hover, click, and focus it | Hover Popover waits
~500 ms instead of 150 ms. Click and keyboard focus still open it
immediately. |
| Inbox and draft controls | Home inbox open-context/more-actions
controls, inbox-list controls, draft detail, and drafts panel | Tooltips
wait ~500 ms instead of the inbox detail's former 200 ms/local defaults.
Clicks remain immediate. |
| Profile and public-key previews | Hover avatars, names/mentions,
project author identities, and displayed public keys | Profile/pubkey
Popovers open after ~500 ms, matching their prior behavior; the change
centralizes that timing. Click actions and focus behavior remain
immediate. |
| Agent, team, memory, and update controls | Managed-agent rows, team
identity cards, restart-diff badges, memory actions, setup steps, and
update indicator | Tooltips wait ~500 ms and do not cascade when
traversing adjacent controls. Actions remain immediate. |
| Huddle controls | Huddle bar/indicator, mic controls, and
participant-list actions | Tooltips wait ~500 ms; mute, join,
participant, and keyboard actions remain immediate. |
| Projects and activity surfaces | Project/repository cards, overview
rail, contribution graph, activity feed, reviewers, and Pulse note
controls | Tooltips wait ~500 ms with a fresh dwell between adjacent
targets. Clicking/focusing interactive controls remains immediate. |
| Intentional interaction-mode exceptions | Focus a Tooltip trigger;
click/focus an ordinary Popover; move the pointer from an open hover
Popover into its panel | Tooltip focus and Popover click/focus open
immediately because they are explicit user intent, not incidental hover.
Hover Popovers retain their 180–200 ms close grace so the pointer can
cross into the panel. The Community actions navigation submenu is the
intentional exception: it opens after 80 ms and keeps its 160 ms pointer
bridge; informational hover Popovers use the shared 500 ms delay. |
## Verification
- `pnpm typecheck`
- `pnpm check` (passes with three existing diagnostics outside this
diff)
- `pnpm test` — 4,775 passed
- `pnpm build`
---------
Signed-off-by: Taylor Ho
---
.../channels/ui/AddChannelBotTeamsSection.tsx | 2 +-
.../features/channels/ui/BotActivityBar.tsx | 10 ++-
.../communities/ui/CommunitySwitcher.tsx | 10 ++-
.../src/features/home/ui/InboxDetailPane.tsx | 2 +-
.../features/messages/ui/MessageReactions.tsx | 12 ++-
.../features/messages/ui/MessageTimeline.tsx | 2 +-
.../features/messages/ui/MessageTimestamp.tsx | 46 ++++-------
.../profile/ui/UserProfilePopover.tsx | 10 ++-
.../sidebar/ui/ChannelActivityPopover.tsx | 10 ++-
desktop/src/main.tsx | 2 +-
desktop/src/shared/ui/PubKey.tsx | 10 ++-
.../shared/ui/markdown/InlineEmojiPopover.tsx | 12 ++-
desktop/src/shared/ui/popover.tsx | 4 +
desktop/src/shared/ui/sidebar.tsx | 2 +-
desktop/src/shared/ui/tooltip.tsx | 18 ++++-
desktop/tests/e2e/community-rail.spec.ts | 81 ++++++++++++++++++-
.../e2e/composer-tooltip-dismiss.spec.ts | 31 ++++++-
17 files changed, 207 insertions(+), 57 deletions(-)
diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx
index 69e511fb40d..512ee6899fb 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 d685a961030..cfa84f02e3c 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 cd530e69081..4d6f2d258ba 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 9bb593087d0..4b64ad10e06 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/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx
index d4bec8db6c5..fba878dbb5b 100644
--- a/desktop/src/features/messages/ui/MessageReactions.tsx
+++ b/desktop/src/features/messages/ui/MessageReactions.tsx
@@ -12,7 +12,12 @@ import {
isPositiveEmojiParticle,
useEmojiBurst,
} from "@/shared/ui/EmojiBurstProvider";
-import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const REACTION_PILL_BASE_CLASSES =
@@ -379,7 +384,10 @@ function ReactionPill({
const handleMouseEnter = React.useCallback(() => {
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/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index f8c5395b17a..df9af09e674 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 ? (
-
-
-
- {displayTime}
-
-
-
- {formatFullDateTime(createdAt)}
-
-
-
+
+
+
+ {displayTime}
+
+
+
+ {formatFullDateTime(createdAt)}
+
+
);
}
diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx
index ca732bb3f1f..257256c5f63 100644
--- a/desktop/src/features/profile/ui/UserProfilePopover.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx
@@ -28,7 +28,12 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions";
-import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import {
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from "@/shared/ui/popover";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useNow } from "@/shared/lib/useNow";
import { Button } from "@/shared/ui/button";
@@ -51,7 +56,6 @@ type UserProfilePopoverProps = {
botIdenticonValue?: string;
};
-const HOVER_OPEN_DELAY_MS = 500;
const HOVER_CLOSE_DELAY_MS = 200;
const RUNTIME_LABELS: Record
= {
@@ -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/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx
index 1c86ac4c36b..e6e24ce27b1 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/main.tsx b/desktop/src/main.tsx
index bbb4c5fa425..1520814559f 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -86,7 +86,7 @@ function renderApp() {
enabled={huddleWindowChannelId() === null}
>
-
+
diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx
index 153801787ba..fb5ac15a441 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/InlineEmojiPopover.tsx b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx
index 89b482ae0cf..1082ff20322 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 4c161efe5d8..ede0e15627d 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/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx
index 568bb469182..5dbda329876 100644
--- a/desktop/src/shared/ui/sidebar.tsx
+++ b/desktop/src/shared/ui/sidebar.tsx
@@ -251,7 +251,7 @@ const SidebarProvider = React.forwardRef<
return (
-
+
) => (
+
+);
const Tooltip = TooltipPrimitive.Root;
diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index 8d24ef7e1df..57b35db386e 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -296,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();
diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts
index 9b0bb051fc9..d6cd05eb0f3 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)");
From b74700daafa823e56c60b4e6470740ab28330888 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 18 Aug 2026 13:56:06 -0600
Subject: [PATCH 14/27] chore(hooks): keep mobile analysis out of pre-commit
(#6236)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- run only Dart formatting for mobile changes during pre-commit
- move Flutter static analysis to the path-scoped pre-push graph
- fix case-sensitive `Justfile` triggers for the Rust and Tauri pre-push
gates
- skip whole-tree desktop/web formatting for lockfile-only commits while
retaining every lockfile-triggered pre-push check
No test suite was removed or narrowed. Cargo formatting remains
workspace-scoped; frontend and mobile source changes still select their
existing formatters.
## Benchmark
Warm isolated timings on an M2 Max:
- `dart format .`: 1.92–2.23s
- `flutter analyze`: 5.58–7.51s
- old forced full pre-commit: 10.16–18.40s
- current forced full pre-commit at `f18d9b5`: 5.42–7.09s, median 6.29s
(45% lower)
- current forced full pre-push at `f18d9b5`: 2m45s, effectively
unchanged from the prior warm 2m41s run
- lockfile-only pre-commit after the follow-up: 0.18–0.19s across three
runs
The new mobile analysis lane finishes before the existing mobile test
lane, so it added no observed full-push wall time.
## Validation
At pushed head `79626be60d89ba34e0fe136f254cc38cf1f1c2b8`:
- `lefthook validate`
- isolated temporary-repository selection test:
- `Justfile` selects both Rust and Tauri pre-push gates
- `pnpm-lock.yaml` selects neither mutating frontend formatter
- desktop/web source files still select their formatter
- lockfile-only pre-commit: 0.19s, 0.19s, 0.18s
- pre-push hook passed on the exact pushed head
Earlier full-cycle validation at
`f18d9b5802f11543e8afbef3cd54c5928817e32b`:
- forced full pre-commit: 5.42s, 6.29s, 7.09s
- forced full pre-push: 2m45s; all lanes passed
- mobile tests: 1,465 passed
---------
Signed-off-by: Wes
Co-authored-by: Carl
---
lefthook.yml | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/lefthook.yml b/lefthook.yml
index d3c8bddb83e..b2b9f18894a 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
From 50a71137e6f1c56f66e2f7348a917b2d2a1798f0 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Tue, 18 Aug 2026 16:29:28 -0400
Subject: [PATCH 15/27] feat(managed-agents): close five Claude Code
agent-config gaps (#4557)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes five Claude Code agent-config gaps in Buzz Desktop, split into
three commits that share the spawn-time and live-switch surfaces.
## Config isolation, model authority, and Auto mode (#2692, #2884,
#3493)
- **`CLAUDE_CONFIG_DIR` isolation (#3493).** `config_bridge` resolves
both `settings.json` and `.claude.json` panel paths from the agent's
effective env (`resolve_effective_agent_env` — baked floor → definition
→ global → persona → record), so the panel reads the same directory the
agent runs against. `mcp_config_file_path_for_runtime` honors a custom
dir; empty/blank is treated as unset, matching Claude's
`CLAUDE_CONFIG_DIR || homedir()` semantics. `AgentConfigPanel` shows a
Keychain caveat when a custom dir is active (a custom dir maps to a
fresh Keychain namespace unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is
also set).
- **Single startup model authority (#2692).** `ANTHROPIC_MODEL` is the
sole startup model authority for Claude. Local spawns write
`ANTHROPIC_MODEL` and strip `BUZZ_ACP_MODEL` so the harness never sees
two authorities; remote deploys send `ANTHROPIC_MODEL` in `policy_env`
instead of `BUZZ_ACP_MODEL`. Non-Claude runtimes are unchanged.
- **`PermissionMode::Auto` (#2884).** Wire string `"auto"`, model-gated,
degrades to the agent default when the active model doesn't advertise
it.
## Thinking effort end-to-end for local Claude agents
Effort flows from the running session's discovered `thought_level`
config option through the config surface to a local-only write control
and a read-only two-facts display.
- **Reader.** Discovers the `thought_level` config option from the
session cache (never hardcoded) and populates `effort_config_id` /
`effort_options` on `RuntimeConfigSurface`. The canonical effort tier
orders record env > `record.effort_level` (`BuzzExplicit`) > ACP >
persona > global > definition > file, so the panel shows the effort the
next spawn will launch with while `resolve_with_override` surfaces the
running ACP value as the struck-through override — neither masks the
other silently.
- **Write control.** `persist_agent_effort_level` is a direct-write
Tauri setter (writes `record.effort_level` + `updated_at`,
`save_managed_agents`) that rejects non-local backends — remote effort
is set at deploy time via `policy_env`. `EffortPickerField` mounts in
`AgentInstanceEditDialog` beside the Model block, gated on a local
backend **AND** a discovered `effortConfigId`. It persists directly and
invalidates the config surface, mirroring the
`setManagedAgentAutoRestart` standalone-setter precedent, so the frozen
`UpdateManagedAgentInput` shape stays frozen.
- **Display.** The read-only configured-vs-current two-facts display is
the `thinkingEffort` normalized field in `AgentConfigPanel`, fed by the
reader's canonical tier ordering.
- `buzz-acp` applies the startup effort env at session start.
## Distinguish a rejected model switch from silent success
A live model switch collapsed adapter rejection into success:
`apply_model_switch` returned `Ok` on both an accepted switch and an
application-level refusal, so the caller cached pre-switch capabilities
as if they described the target model and Desktop reported the pick as
landed.
- `ModelSwitchOutcome::{Applied(Value),Rejected}`. Transport-class
errors still propagate as `Err` (respawn the poisoned stdio); an
application-level refusal is now `Rejected`.
- The caller drives everything off `post_switch_snapshot`: `Applied`
refreshes `model_capabilities` from the target model's echoed
`configOptions` (or drops to `None` when none are echoed, so a
pre-switch snapshot is never mistaken for the target's); `Rejected`
preserves pre-switch caps and emits a `failure` `control_result`. Effort
resolution and the session-config capture read the post-switch snapshot
so they converge on the model the session actually runs;
`modelOverridden` is gated on `switch_succeeded`.
- `liveSwitchOutcome.ts` gains a distinct `"failed"` outcome for the
adapter `failure` frame and treats the busy-path `"sent"` ack as
provisional — it never counts toward success. Success is confirmed only
by a real positive terminal frame (the busy-path deferred apply emits a
correlated `switched` `control_result` when it lands), and the fallback
timeout resolves an honest `"pending"` (accepted, apply deferred), never
a false `"ok"`. `ModelPicker` surfaces a distinct toast per outcome —
failed, unsupported, and pending.
## Scope explicitly excluded
Per-agent config dir provisioning, `CLAUDE_SECURESTORAGE_CONFIG_DIR`
sentinel injection, `settings.json` projection, protected-key stripping,
MCP inheritance, spawn serialization, and the `last_spawn_warnings`
surface are absent from this diff. Silent-fallback machinery for
non-Claude runtimes (#2265/#4004) is a tracked follow-up.
## Sanctioned follow-ups
- **Live mid-conversation effort switching.** Effort is spawn-scoped
only in this PR: the worker reads `BUZZ_ACP_EFFORT_LEVEL` once and
applies it at session creation. The live effort-switch machinery
(mid-conversation effort RPC + ack frame) was deliberately removed and
is archived on `archive/claude-config-gaps-live-effort` for a future
plan-gated revival.
- **Idle-path late model-switch rejection is unobservable.** An idle
switch acks `switched` immediately after catalog validation, but the
real `set_config_option` runs at the next session creation — potentially
much later — so a rejection there is not surfaced back to the picker
(holding a subscription that long is not sensible). Pre-existing,
catalog-gated behavior; a durable fix is a tracked follow-up.
Closes #2692, #2884, #3493
---------
Signed-off-by: Will Pfleger
Co-authored-by: Duncan
---
crates/buzz-acp/src/acp.rs | 70 +
crates/buzz-acp/src/config.rs | 38 +
crates/buzz-acp/src/lib.rs | 30 +-
crates/buzz-acp/src/pool.rs | 1231 ++++++++++++++++-
crates/buzz-backend-kubernetes/src/env.rs | 85 ++
.../src-tauri/src/commands/agent_config.rs | 78 +-
.../src/commands/agent_config_tests.rs | 46 +-
desktop/src-tauri/src/commands/agents.rs | 7 +-
.../src-tauri/src/commands/agents_deploy.rs | 267 +++-
.../src-tauri/src/commands/agents_tests.rs | 1 +
.../commands/personas/delete_cascade_tests.rs | 1 +
.../personas/inbound/inbound_tests.rs | 1 +
.../personas/snapshot/fidelity_tests.rs | 1 +
.../src/commands/personas/snapshot/import.rs | 1 +
.../src/commands/personas/snapshot/tests.rs | 1 +
.../personas/update/name_propagation_tests.rs | 1 +
.../src-tauri/src/commands/team_snapshot.rs | 1 +
.../src/commands/team_snapshot/tests.rs | 1 +
desktop/src-tauri/src/lib.rs | 20 +-
.../src/managed_agents/agent_events.rs | 1 +
.../managed_agents/agent_snapshot_envelope.rs | 1 +
.../managed_agents/agent_snapshot_tests.rs | 1 +
.../src/managed_agents/claude_config/mod.rs | 53 +
.../src/managed_agents/claude_config/tests.rs | 127 ++
.../managed_agents/config_bridge/claude.rs | 42 +-
.../managed_agents/config_bridge/reader.rs | 116 +-
.../config_bridge/reader_tests.rs | 66 +-
.../config_bridge/reader_tests_ext.rs | 282 +++-
.../src/managed_agents/config_bridge/types.rs | 19 +
.../src/managed_agents/discovery/tests.rs | 4 +-
.../managed_agents/effective_config/tests.rs | 1 +
.../src/managed_agents/global_config/tests.rs | 1 +
desktop/src-tauri/src/managed_agents/mod.rs | 1 +
.../src/managed_agents/nest/tests.rs | 1 +
.../src/managed_agents/parallelism.rs | 1 +
.../managed_agents/persona_events/tests.rs | 1 +
.../src-tauri/src/managed_agents/readiness.rs | 6 +-
.../src-tauri/src/managed_agents/runtime.rs | 40 +-
.../managed_agents/runtime/test_fixtures.rs | 1 +
.../src/managed_agents/spawn_snapshot.rs | 43 +-
.../src/managed_agents/spawn_snapshot/diff.rs | 1 +
.../spawn_snapshot/diff/tests.rs | 36 +
.../managed_agents/spawn_snapshot/tests.rs | 11 +
.../spawn_snapshot/tests_ext.rs | 189 +++
.../src/managed_agents/team_snapshot.rs | 1 +
.../src/managed_agents/teams_tests.rs | 1 +
desktop/src-tauri/src/managed_agents/types.rs | 25 +-
.../src/managed_agents/types/relay_mesh.rs | 19 +
desktop/src/features/agents/AGENTS.md | 39 +
.../agents/lib/liveSwitchOutcome.test.mjs | 395 +++++-
.../features/agents/lib/liveSwitchOutcome.ts | 133 +-
.../src/features/agents/observerRelayStore.ts | 39 +-
.../features/agents/ui/AgentConfigPanel.tsx | 34 +-
.../agents/ui/AgentInstanceEditDialog.tsx | 28 +-
.../features/agents/ui/EffortPickerField.tsx | 81 ++
.../agents/ui/McpServersSection.test.mjs | 90 ++
.../features/agents/ui/McpServersSection.tsx | 24 +
.../src/features/agents/ui/ModelPicker.tsx | 54 +-
.../features/agents/ui/effortPicker.test.mjs | 110 ++
.../src/features/agents/ui/effortPicker.ts | 71 +
desktop/src/shared/api/agentControl.ts | 6 +
desktop/src/shared/api/tauriManagedAgents.ts | 15 +
desktop/src/shared/api/types.ts | 36 +-
63 files changed, 3847 insertions(+), 280 deletions(-)
create mode 100644 desktop/src-tauri/src/managed_agents/claude_config/mod.rs
create mode 100644 desktop/src-tauri/src/managed_agents/claude_config/tests.rs
create mode 100644 desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs
create mode 100644 desktop/src-tauri/src/managed_agents/types/relay_mesh.rs
create mode 100644 desktop/src/features/agents/ui/EffortPickerField.tsx
create mode 100644 desktop/src/features/agents/ui/McpServersSection.test.mjs
create mode 100644 desktop/src/features/agents/ui/effortPicker.test.mjs
create mode 100644 desktop/src/features/agents/ui/effortPicker.ts
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index f8373bd66d8..0d87bca028c 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -2182,6 +2182,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.
@@ -2751,6 +2773,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 f9e7bf1ed8a..5244ef5537a 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 2a41ea73420..68b2df4d607 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,
@@ -4701,6 +4717,7 @@ struct PoolStartup {
extra_env: Vec<(String, String)>,
has_generated_codex_config: bool,
model: Option,
+ effort_level: Option,
observer: Option,
}
@@ -4713,6 +4730,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,
}
}
@@ -4780,6 +4798,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,
@@ -7139,6 +7160,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,
@@ -7362,6 +7384,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,
@@ -7408,6 +7431,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 2efacce2b19..6e3a9b24fa5 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
}
@@ -1044,17 +1083,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!(
@@ -1071,26 +1187,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.
@@ -1139,18 +1293,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})")
@@ -1175,11 +1346,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.
@@ -1192,14 +1367,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
@@ -1208,10 +1387,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`.
@@ -2216,9 +2508,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.
@@ -2309,7 +2607,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",
@@ -3691,7 +3989,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,
};
@@ -4411,6 +4709,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();
@@ -5651,6 +5983,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,
@@ -5745,6 +6080,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,
@@ -5917,6 +6255,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,
@@ -6067,6 +6408,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,
@@ -6480,7 +6824,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));
@@ -6520,7 +6867,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),
@@ -6636,7 +6986,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),
@@ -7054,6 +7407,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,
@@ -7112,6 +7468,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,
@@ -7547,7 +7906,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)
}
@@ -8139,3 +8498,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 badff621e8d..5fc27ab9055 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/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs
index 2dc0ba0d699..4df24e6e9ba 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 77e43f5d646..9c9aa58c1fd 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/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index d1667a72bb4..ed7a33d397d 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 483eb60134f..da5bb3ba5c0 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 0176478bc5e..61a2d8a1459 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/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs
index f4590f5d6e7..a4bbdeb677c 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 c0526222151..fbfede35886 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 6d7a2e6264b..341426fe940 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 7d3fd95ff34..75a1edea65e 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 5e8cea52e69..fedb0e60585 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 72bdca7de9c..556127373bf 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 8315f39a362..e4c08a14be0 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 a466228160a..bec7f43bf8a 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/lib.rs b/desktop/src-tauri/src/lib.rs
index cefdccfd69f..31d4ff37133 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 f70c714323e..f0a4fabfed8 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_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
index 751452aa7be..de2f71577a6 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 fca15111d0a..de79df92e84 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,
}
}
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 00000000000..647ea56209e
--- /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 00000000000..f6f0f90cb2d
--- /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 449197a3b31..b54297df800 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 c51f325cf3b..93827635e90 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 0e7070724d4..36b6022b53b 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 8613124f259..f86793f91a1 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 6ca2592538a..3842825fe8e 100644
--- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs
+++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs
@@ -175,6 +175,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.
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index e53c9114ab7..f7e233fbe95 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 ee18e554c30..5b048b815cb 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 b2a56870c73..65cde47f26b 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 16234aa3d69..272c03348b9 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/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs
index 67cdb5fbaf1..672094d6c2b 100644
--- a/desktop/src-tauri/src/managed_agents/nest/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs
@@ -503,6 +503,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> 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/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs
index a6a50540bbe..734772d73d9 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 682fbef62fa..af8cfe66182 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 c0055109077..f7f5d5c5d0e 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 8b57b161b77..923530d34b9 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 792a275b059..9076766b2e6 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 357f5f1e26d..8a6f68a693d 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 a61eb92e2e5..0ae3009bae3 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 a7a8cab93e7..e21dc4735c7 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 89bba15cee4..b007e0b2ffa 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 00000000000..dd708b6e59e
--- /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 5073d9c4070..2b6918b16e4 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 d66e68979cb..ff7900d3923 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 3b0641cb677..9049482de3a 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 00000000000..a9ec2d28388
--- /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/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index fa352284dd9..88f2a3c9821 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 737d84b8620..4a79d32837b 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, "must not resolve before the last ack");
+ assert.equal(settled, false, "an unknown status must not settle the pick");
- h.push(frame("turn_ending"));
+ h.push(frame("switched"));
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();
- assert.equal(settled, false, "unrelated frames must not advance the count");
+
+ 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 resolves ok via the timeout fallback when the harness never replies", async () => {
- const h = harness(2);
+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 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, "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 d12261e5968..83792dcdab2 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 88e8bba7547..68fa290ad25 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);
-
- for (const parsed of 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 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/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx
index 046bd13c473..e5f3a745309 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 f7ee098833b..62d85d385d4 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 00000000000..a06f17ac11f
--- /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 00000000000..526acbca5e3
--- /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 f92f77a3923..db3de3b2001 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 863cd851195..0bc6f9646af 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/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs
new file mode 100644
index 00000000000..28c22ec9d95
--- /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 00000000000..515355e4ad7
--- /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/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts
index 677f0ffad49..70fb7529396 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/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts
index c74b099f885..88bce2ec275 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 dcf6d2e8bc7..41c63f7be97 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 = {
From 4f9727a4b3d76389f862faa15241e16e2dd36108 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Tue, 18 Aug 2026 16:49:41 -0400
Subject: [PATCH 16/27] chore(scripts): add buzz-adopt-prod-agents.sh (#6250)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `scripts/buzz-adopt-prod-agents.sh`, which copies the installed-DMG
(production) agent records and owner identity into the dev app-data
store so a dev build boots as the **same agents and same owner npub** as
the installed app — surviving `just reset`.
## What it restores
`just reset` wipes the two things Buzz cannot re-derive:
- The agent **records** — `managed-agents.json`, `personas.json`,
`teams.json`, and `agents/teams/`.
- The **owner identity** nsec, written as `identity.key` (0600, atomic).
Agent private keys and identity-key adoption then happen automatically
on the next dev boot via the shipped in-app migrations.
## When to run it
1. Quit any running **dev** build. A running installed DMG is fine — the
script treats it as a read-only source and only warns against
creating/archiving agents or editing teams while it runs. It never kills
processes.
2. Run `just reset`.
3. Before the first dev boot, run `bash
scripts/buzz-adopt-prod-agents.sh`.
- `--dry-run` prints every action with zero writes.
- `--force` overwrites an already-populated dev store.
- One keychain prompt is expected (reading the prod owner identity).
4. Start the dev build. First boot copies the agent keys prod→dev and
adopts `identity.key`, then deletes it. For worktree launches, export
`BUZZ_SHARE_IDENTITY=1` (and `BUZZ_PRIVATE_KEY`) or the worktree mints
its own duplicate agents.
## Running-process check
Only a running **dev** build blocks the run: the script atomically swaps
the entire dev `agents/` directory and stages `identity.key` for the
next dev boot to consume, so doing that under a live dev process is the
corruption the check must prevent. A running installed DMG is allowed —
the app writes `managed-agents.json` via tmp+atomic-rename, so it can
never hand the script a torn file. Each matching `buzz-desktop` PID is
classified by its true post-exec executable path (via `lsof -d txt`,
which reports the running vnode with symlinks already resolved); the
installed `/Applications/Buzz.app/Contents/MacOS/buzz-desktop` path is
the only allow. Anything else still alive blocks fail-closed; a PID that
exits before its path can be resolved is ignored.
## Requirements
Requires `python3` when a dev `agents/` already exists — it drives the
`renamex_np(RENAME_SWAP)` syscall bash cannot call. The script fails in
preflight with a clear message if `python3` is absent, never mid-commit.
## Safety
Prod is strictly read-only; symlinked or aliased prod/dev roots are
refused before any write. The records/identity restore commits in one
atomic operation (`renamex_np(RENAME_SWAP)` when a dev store exists, a
single `mv` when absent) that fails closed — a crash or I/O error leaves
`agents/` either fully old or fully new, and unrelated live dev state
(`logs/`, `agent-pids/`, `global-agent-config.json`, retention stores)
is preserved verbatim across the swap.
This is a temporary measure while we trial it and decide whether to port
the logic into the dev-build startup path.
Signed-off-by: Will Pfleger
Co-authored-by: Duncan
---
scripts/buzz-adopt-prod-agents.sh | 479 ++++++++++++++++++++++++++++++
1 file changed, 479 insertions(+)
create mode 100755 scripts/buzz-adopt-prod-agents.sh
diff --git a/scripts/buzz-adopt-prod-agents.sh b/scripts/buzz-adopt-prod-agents.sh
new file mode 100755
index 00000000000..01e02022e03
--- /dev/null
+++ b/scripts/buzz-adopt-prod-agents.sh
@@ -0,0 +1,479 @@
+#!/usr/bin/env bash
+#
+# buzz-adopt-prod-agents.sh — copy your installed (production) Buzz agent
+# records + owner identity into the dev app-data store so a dev build boots as
+# the SAME agents and the SAME owner npub as your installed DMG.
+#
+# WHY
+# `just reset` deletes the entire dev app-data dir (agent records included),
+# every buzz-desktop-dev keychain entry, and the `_dev_migration_v1` marker.
+# The next dev boot sees an empty store, so key-less records mint fresh
+# keypairs against the prod relay → duplicate agent instances, and a fresh
+# owner key → auth-tag split-brain. This script restores the two things the
+# reset wipes that Buzz cannot re-derive on its own: the agent RECORDS and
+# the OWNER identity nsec. Everything else the app repairs itself on next boot
+# (see WHAT THIS DOES NOT DO).
+#
+# WHEN
+# Run AFTER `just reset`, BEFORE the first dev boot (`just production` / dev).
+# Any running DEV build must be closed; the installed DMG may stay open (it is
+# only a read-only source here — see preflight 1a). Idempotent: refuses to
+# clobber a non-empty dev store unless --force.
+#
+# WHAT THIS DOES NOT DO (verified against block/buzz origin/main)
+# - It does NOT touch the buzz-desktop-dev keychain. Agent private keys are
+# copied prod→dev automatically at next dev boot by
+# `migrate_agent_keys_to_dev_service` (desktop/src-tauri/src/managed_agents/
+# storage.rs:460): the reset wiped the `_dev_migration_v1` marker, so it
+# re-runs and copies `agent:` keys for every pubkey in the restored
+# records. It only ever failed to help before because the reset also wiped
+# the RECORDS — step "records" below fixes exactly that.
+# - The owner identity is written as a plaintext `identity.key` file, NOT into
+# the dev keychain. Next dev boot's legacy-import path adopts it into the
+# dev keyring (app_state.rs:583, `ReachableButEmpty` branch =
+# the post-reset empty-dev-keyring state) with the crash-safe
+# marker-before-delete ordering already shipped. The plaintext file exists
+# only until that first boot consumes it.
+#
+# FLAGS
+# --dry-run Print every action; write nothing.
+# --force Proceed even if the dev store is non-empty (overwrites records
+# and identity.key). Never bypasses the running-dev-build check.
+# -h|--help This header.
+#
+# PATH SAFETY
+# The prod store is strictly read-only — nothing under prod is ever created,
+# read-modified, or deleted. Prod and dev are hardcoded canonical constants,
+# so there is no override path to canonicalize; the only writes go to the
+# fixed dev dir. As a guard against odd on-disk state, the script refuses to
+# run if the dev store dir/agents subdir is a symlink, or if the prod root or
+# any prod source (agents/, the record files, agents/teams/) is a symlink, or
+# if prod and an existing dev resolve to the same or overlapping tree.
+#
+# ENV OVERRIDES (read-only against prod; exist so the identity read can be
+# fixture-tested against a scratch keychain without touching real secrets)
+# BUZZ_KEYCHAIN_SVC keychain service (default buzz-desktop)
+# BUZZ_KEYCHAIN_ACCT keychain account (default secrets)
+#
+set -euo pipefail
+
+# --- config -----------------------------------------------------------------
+# Prod/dev app-data dirs are hardcoded canonical constants — the only paths a
+# real invocation ever uses. Keychain service/account stay overridable because
+# they are read-only against prod (used to fixture-test the identity read).
+SUPPORT="$HOME/Library/Application Support"
+PROD_DIR="$SUPPORT/xyz.block.buzz.app"
+DEV_DIR="$SUPPORT/xyz.block.buzz.app.dev"
+KEYCHAIN_SVC="${BUZZ_KEYCHAIN_SVC:-buzz-desktop}"
+KEYCHAIN_ACCT="${BUZZ_KEYCHAIN_ACCT:-secrets}"
+
+DRY_RUN=0
+FORCE=0
+
+RECORD_FILES=(managed-agents.json personas.json teams.json)
+
+# --- helpers ----------------------------------------------------------------
+say() { printf '%s\n' "$*"; }
+info() { printf ' %s\n' "$*"; }
+warn() { printf 'WARN: %s\n' "$*" >&2; }
+die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
+
+usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0; }
+
+have() { command -v "$1" >/dev/null 2>&1; }
+
+# Temp paths (files/dirs) to remove on exit. A cleanup FUNCTION consumes this
+# array so no path is ever interpolated into trap source — a path containing
+# an apostrophe or space cannot break the trap or leave a temp behind.
+STAGE_TEMPS=()
+# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup_stages EXIT`
+cleanup_stages() {
+ local p
+ for p in "${STAGE_TEMPS[@]:+${STAGE_TEMPS[@]}}"; do
+ [[ -e "$p" ]] && rm -rf "$p"
+ done
+ return 0 # never let the trap's last test override the script's exit status
+}
+trap cleanup_stages EXIT
+
+# Create a mode-0600 staging file next to its final destination so the later
+# rename is same-filesystem atomic. mktemp creates 0600 by default; the umask
+# guards against a lenient inherited default. Echoes the temp path.
+new_stage() { ( umask 077; mktemp "$1.stage.XXXXXX" ); }
+
+# Atomically move a validated 0600 stage file over its destination. `mv -f`
+# replaces the name (never writes through a symlink at $2) so an interrupted
+# run can never leave a partial file at the live path.
+finalize() { chmod 600 "$1"; mv -f "$1" "$2"; }
+
+# Atomically exchange two existing directories via renamex_np(RENAME_SWAP) — the
+# whole-bundle commit primitive. bash cannot call the syscall, so python3 drives
+# it. Fail closed: any unavailability or nonzero syscall result exits nonzero so
+# the caller aborts with the live store untouched (never a sequential fallback).
+swap_dirs() {
+ python3 - "$1" "$2" <<'PY'
+import ctypes, os, sys
+a, b = sys.argv[1], sys.argv[2]
+try:
+ libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
+ renamex_np = libc.renamex_np
+except (OSError, AttributeError) as e:
+ sys.stderr.write("renamex_np unavailable: %s\n" % e); sys.exit(1)
+renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
+RENAME_SWAP = 0x2
+if renamex_np(os.fsencode(a), os.fsencode(b), RENAME_SWAP) != 0:
+ e = ctypes.get_errno()
+ sys.stderr.write("renamex_np(RENAME_SWAP) failed: errno %d (%s)\n" % (e, os.strerror(e)))
+ sys.exit(1)
+PY
+}
+
+# Strip the volatile runtime_pid field from a managed-agents store (JSON array
+# of ManagedAgentRecord; field verified at types.rs:317). Reads $1, writes $2.
+# Nonzero exit on malformed input so callers can abort before the rename.
+strip_runtime_pid() {
+ local src="$1" dst="$2"
+ if have jq; then
+ jq 'if type=="array" then map(del(.runtime_pid)) else del(.runtime_pid) end' "$src" > "$dst"
+ elif have python3; then
+ python3 - "$src" "$dst" <<'PY'
+import json, sys
+src, dst = sys.argv[1], sys.argv[2]
+with open(src) as f:
+ data = json.load(f)
+def strip(r):
+ if isinstance(r, dict):
+ r.pop("runtime_pid", None)
+ return r
+data = [strip(r) for r in data] if isinstance(data, list) else strip(data)
+with open(dst, "w") as f:
+ json.dump(data, f, indent=2)
+PY
+ else
+ die "need jq or python3 to strip runtime_pid"
+ fi
+}
+
+# --- arg parse --------------------------------------------------------------
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run) DRY_RUN=1 ;;
+ --force) FORCE=1 ;;
+ -h|--help) usage ;;
+ *) die "unknown argument: $1 (see --help)" ;;
+ esac
+ shift
+done
+
+[[ $DRY_RUN -eq 1 ]] && say "== DRY RUN — no changes will be written =="
+
+# --- step 1: preflight ------------------------------------------------------
+say "[1/4] Preflight"
+
+# 1a. Refuse if a running DEV build is detected; a running installed DMG is
+# allowed (read-only detection; never kills). The main app binary is
+# `buzz-desktop` for both the installed DMG
+# (/Applications/Buzz.app/Contents/MacOS/buzz-desktop) and dev builds
+# (target//buzz-desktop via `tauri dev`). Match that path component
+# exactly so sidecars/helpers (buzz, buzz-dev-mcp, buzz-agent) don't
+# false-positive.
+#
+# WHY dev blocks but the DMG doesn't: step 2 atomically swaps the entire dev
+# agents/ dir and step 3 stages identity.key for the next dev boot to
+# consume — doing that under a live dev process is exactly the corruption
+# the check must prevent. The DMG is only a read-only source: the app writes
+# managed-agents.json via tmp+atomic-rename (managed_agents/storage.rs:603
+# on origin/main), so a running DMG can never hand us a torn file. The only
+# residual is cross-file skew if agents/teams are mutated in the DMG during
+# the seconds this runs — a warn, self-healing on re-run, not a block.
+#
+# Classification per PID resolves the true post-exec executable path via
+# `lsof -d txt` (the running vnode, symlinks already resolved by the kernel;
+# argv[0]/ps comm can lie). Fail closed: the DMG path is the ONLY allow;
+# anything else that is still alive blocks. A PID that exits between pgrep
+# and resolution (path unresolvable AND process gone) is ignored — it is no
+# longer running. A PID still alive but unresolvable (permissions, exotic
+# state) blocks.
+ALLOWED_PROD_EXE="/Applications/Buzz.app/Contents/MacOS/buzz-desktop"
+
+# Echo a PID's true executable path (first txt-mapped vnode), or empty if none.
+# lsof exits nonzero when the PID is gone; callers use `|| true` so a raced exit
+# under `set -e` yields an empty path (handled as raced-exit) instead of aborting.
+pid_exe_path() { lsof -a -p "$1" -d txt -Fn 2>/dev/null | awk '/^n/{print substr($0,2); exit}'; }
+
+# True only for the documented clean no-match — the PID has genuinely exited.
+# `ps -p` fails BOTH for a gone PID and for other errors, and `kill -0` fails
+# both for ESRCH and EPERM (a live process the caller can't signal), so neither
+# status alone distinguishes gone from alive-but-indeterminate. Classify by all
+# three signals and fail closed: gone == the ONE documented clean no-match shape
+# (macOS `ps` exits EXACTLY 1 with empty stdout and empty stderr). A live PID
+# (incl. one we lack permission to signal) exits 0 and prints its pid; any other
+# status, any stderr diagnostic, or a setup failure (mktemp/rm) is indeterminate
+# and returns non-gone → blocking. `pid_gone` runs as an `elif` condition where
+# `errexit` is suppressed, so it must classify failures explicitly rather than
+# rely on `set -e` to abort — hence the explicit `|| return 1` on setup steps.
+pid_gone() {
+ local out err rc
+ err="$(mktemp)" || return 1
+ if out="$(ps -p "$1" -o pid= 2>"$err")"; then rc=0; else rc=$?; fi
+ local had_err=0; [[ -s "$err" ]] && had_err=1
+ rm -f "$err" || return 1
+ [[ $rc -eq 1 && -z "$out" && $had_err -eq 0 ]]
+}
+
+running_pids="$(pgrep -f '/buzz-desktop( |$)' 2>/dev/null || true)"
+dmg_running=0
+dev_blocking=() # "pid:reason" for each PID that blocks the run
+if [[ -n "$running_pids" ]]; then
+ # Canonicalize the allow-path once (resolve any symlink in the DMG bundle
+ # path) so a symlinked DMG binary still matches the resolved lsof vnode.
+ allowed_real="$(realpath "$ALLOWED_PROD_EXE" 2>/dev/null || printf '%s' "$ALLOWED_PROD_EXE")"
+ while IFS= read -r pid; do
+ [[ -n "$pid" ]] || continue
+ exe="$(pid_exe_path "$pid" || true)"
+ exe_real="$(realpath "$exe" 2>/dev/null || printf '%s' "$exe")"
+ if [[ -n "$exe" && "$exe_real" == "$allowed_real" ]]; then
+ dmg_running=1
+ elif pid_gone "$pid"; then
+ : # process exited between pgrep and resolution — no longer running, ignore
+ else
+ # Alive (or indeterminate) and not the allowed DMG path: dev build,
+ # unresolvable exe, or a liveness probe we can't trust — fail closed.
+ dev_blocking+=("$pid:${exe:-}")
+ fi
+ done <<< "$running_pids"
+fi
+if [[ ${#dev_blocking[@]} -gt 0 ]]; then
+ warn "A non-installed buzz-desktop process is running (dev build or unresolvable):"
+ for entry in "${dev_blocking[@]}"; do warn " PID ${entry%%:*} → ${entry#*:}"; done
+ warn "Quit any running dev build, then re-run. This script never kills processes."
+ exit 1
+fi
+if [[ $dmg_running -eq 1 ]]; then
+ info "installed DMG is running — allowed (read-only source)"
+ warn "Do NOT create/archive agents or edit teams in the DMG while this runs; the 4-file bundle is read as one snapshot."
+else
+ info "no running Buzz process detected"
+fi
+
+# 1b. On-disk symlink refusals. The paths are fixed canonical constants, so
+# there is no override spelling to canonicalize — but if something odd
+# already exists at those paths (a symlinked dev store, or a prod source
+# aliased elsewhere), following it would break the read-only promise or
+# write outside the dev dir. Reject symlinks before any mkdir/write.
+[[ -d "$PROD_DIR" ]] || die "prod app-data dir not found: $PROD_DIR"
+# Prod root must be a real directory, never a symlink: a symlinked prod root
+# could alias the dev tree, making source and destination the same path, so the
+# read-only prod promise would break and the commit could destroy prod data.
+[[ -L "$PROD_DIR" ]] && die "refusing: prod app-data dir is a symlink; must be a real directory."
+# Dev write targets: mkdir -p / the directory exchange would otherwise follow a
+# symlink here and write outside the dev dir.
+for d in "$DEV_DIR" "$DEV_DIR/agents"; do
+ [[ -L "$d" ]] && die "refusing: $d is a symlink; write target must be a real directory."
+done
+# Prod source components: a symlinked prod/agents, record file, or teams/ dir
+# could alias a dev target and be mutated or destroyed by the commit. Source
+# must be real files/dirs. (Symlinked prod sources are also copied by value into
+# the stage, silently importing whatever they point at — refuse instead.)
+[[ -L "$PROD_DIR/agents" ]] && die "refusing: prod agents/ is a symlink; source must be a real directory."
+for f in "${RECORD_FILES[@]}"; do
+ [[ -L "$PROD_DIR/agents/$f" ]] \
+ && die "refusing: prod agents/$f is a symlink; source must be a real file."
+done
+[[ -L "$PROD_DIR/agents/teams" ]] \
+ && die "refusing: prod agents/teams is a symlink; source must be a real directory."
+# Physical distinctness: prod and dev must not resolve to the same tree or one
+# inside the other. Both roots are now confirmed non-symlinks, but resolve
+# physically anyway to catch any alias in an ancestor component. Dev is
+# normally absent post-reset (fixed sibling constants can't overlap); only
+# compare when it already exists (a forced re-run).
+prod_phys="$(cd "$PROD_DIR" && pwd -P)"
+if [[ -d "$DEV_DIR" ]]; then
+ dev_phys="$(cd "$DEV_DIR" && pwd -P)"
+ [[ "$prod_phys" == "$dev_phys" || "$prod_phys" == "$dev_phys"/* || "$dev_phys" == "$prod_phys"/* ]] \
+ && die "refusing: prod and dev resolve to the same or overlapping directory (prod=$prod_phys dev=$dev_phys)."
+fi
+info "path checks OK (prod=$PROD_DIR dev=$DEV_DIR)"
+
+# 1c. Prod store must exist and parse.
+prod_agents="$PROD_DIR/agents/managed-agents.json"
+[[ -f "$prod_agents" ]] || die "prod managed-agents.json not found: $prod_agents"
+if have jq; then
+ jq empty "$prod_agents" 2>/dev/null || die "prod managed-agents.json is not valid JSON: $prod_agents"
+elif have python3; then
+ python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$prod_agents" \
+ || die "prod managed-agents.json is not valid JSON: $prod_agents"
+fi
+info "prod store OK: $prod_agents"
+
+# 1d. Dev store must be absent/empty unless --force.
+dev_agents="$DEV_DIR/agents/managed-agents.json"
+if [[ -s "$dev_agents" ]]; then
+ if [[ $FORCE -eq 1 ]]; then
+ warn "dev store is non-empty; --force given, will overwrite: $dev_agents"
+ else
+ die "dev store already populated ($dev_agents). Re-run with --force to overwrite."
+ fi
+else
+ info "dev store absent/empty: $dev_agents"
+fi
+
+# 1e. When a live agents/ already exists, the commit is an atomic directory
+# exchange (renamex_np RENAME_SWAP), which only python3 can drive. Require
+# it here so a missing interpreter fails preflight, never mid-commit.
+if [[ $DRY_RUN -eq 0 && -d "$DEV_DIR/agents" ]] && ! have python3; then
+ die "python3 is required to atomically replace the existing dev agents/ directory."
+fi
+
+# --- step 2: restore records (exact bundle, one atomic commit) --------------
+# The four boot-critical paths (managed-agents.json, personas.json, teams.json,
+# teams/) are read as one related state, so the commit must be all-or-nothing.
+# Phase 1 builds a COMPLETE replacement agents/ in a staging dir beside the live
+# one — the prod bundle plus any unrelated live entries carried across verbatim,
+# every transform validated, zero live-path mutation. Phase 2 commits it in ONE
+# operation: a single rename when no live agents/ exists, otherwise one atomic
+# renamex_np(RENAME_SWAP) exchange (fail closed, never a sequential fallback).
+# A crash or I/O error therefore leaves agents/ either fully old or fully new.
+say "[2/4] Agent records prod → dev"
+if [[ $DRY_RUN -eq 1 ]]; then
+ info "[dry-run] stage a complete replacement agents/ beside the live dir"
+ for f in "${RECORD_FILES[@]}"; do
+ if [[ -f "$PROD_DIR/agents/$f" ]]; then
+ if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then
+ info "[dry-run] stage $f (strip runtime_pid, 0600)"
+ else
+ info "[dry-run] stage $f (0600)"
+ fi
+ elif [[ -e "$DEV_DIR/agents/$f" ]]; then
+ info "[dry-run] drop stale dev $f (absent in prod)"
+ else
+ info "skip (absent in prod): $f"
+ fi
+ done
+ if [[ -d "$PROD_DIR/agents/teams" ]]; then
+ info "[dry-run] stage agents/teams/"
+ elif [[ -e "$DEV_DIR/agents/teams" ]]; then
+ info "[dry-run] drop stale dev agents/teams/ (absent in prod)"
+ else
+ info "skip (absent in prod): agents/teams/"
+ fi
+ if [[ -d "$DEV_DIR/agents" ]]; then
+ info "[dry-run] commit via atomic directory exchange (existing dev agents/)"
+ else
+ info "[dry-run] commit via single rename (no existing dev agents/)"
+ fi
+else
+ # Phase 1 — build the complete replacement in a staging dir on the dev
+ # filesystem. mktemp -d creates it 0700; record files are chmod 0600. Only
+ # $DEV_DIR (the stage parent) is created live — never agents/ — so a phase-1
+ # failure leaves no live agents/ scaffold behind.
+ mkdir -p "$DEV_DIR"
+ stage_agents="$(mktemp -d "$DEV_DIR/agents.stage.XXXXXX")"
+ STAGE_TEMPS+=("$stage_agents")
+
+ # Seed the stage with the entire live agents/ (a single checked copy — under
+ # `set -e` a partial copy aborts before any commit), then drop the four bundle
+ # names FROM THE STAGE so the prod overlay below replaces them cleanly. Any
+ # other live entry (logs/, agent-pids/, global-agent-config.json, retention
+ # stores, …) is carried across verbatim and preserved by the directory swap.
+ if [[ -d "$DEV_DIR/agents" ]]; then
+ cp -Rp "$DEV_DIR/agents/." "$stage_agents/"
+ rm -rf -- \
+ "$stage_agents/managed-agents.json" \
+ "$stage_agents/personas.json" \
+ "$stage_agents/teams.json" \
+ "$stage_agents/teams"
+ fi
+
+ # Stage the prod bundle. A record file absent in prod is simply not staged, so
+ # the swap drops any stale dev copy; the same holds for teams/.
+ for f in "${RECORD_FILES[@]}"; do
+ src="$PROD_DIR/agents/$f"
+ if [[ -f "$src" ]]; then
+ if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then
+ strip_runtime_pid "$src" "$stage_agents/$f" || die "failed to transform $f (dev unchanged)"
+ info "staged $f (runtime_pid stripped, mode 0600)"
+ else
+ cat "$src" > "$stage_agents/$f"
+ info "staged $f (mode 0600)"
+ fi
+ chmod 600 "$stage_agents/$f"
+ elif [[ -e "$DEV_DIR/agents/$f" ]]; then
+ info "dropping stale dev $f (absent in prod)"
+ else
+ info "skip (absent in prod): $f"
+ fi
+ done
+ if [[ -d "$PROD_DIR/agents/teams" ]]; then
+ cp -Rp "$PROD_DIR/agents/teams" "$stage_agents/teams"
+ info "staged agents/teams/"
+ elif [[ -e "$DEV_DIR/agents/teams" ]]; then
+ info "dropping stale dev agents/teams/ (absent in prod)"
+ else
+ info "skip (absent in prod): agents/teams/"
+ fi
+
+ # Phase 2 — commit the complete bundle in one atomic operation.
+ if [[ -d "$DEV_DIR/agents" ]]; then
+ swap_dirs "$stage_agents" "$DEV_DIR/agents" \
+ || die "atomic directory exchange failed (dev agents/ unchanged)."
+ rm -rf "$stage_agents" # holds the swapped-out old tree after the exchange
+ info "committed agents/ via atomic exchange"
+ else
+ mv "$stage_agents" "$DEV_DIR/agents" # no live dir → single rename
+ info "committed agents/ via rename (no prior dev store)"
+ fi
+fi
+
+# --- step 3: owner identity → dev identity.key ------------------------------
+say "[3/4] Owner identity prod keychain → dev identity.key"
+info "reading prod keychain (service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT) — one keychain prompt expected"
+blob="$(security find-generic-password -s "$KEYCHAIN_SVC" -a "$KEYCHAIN_ACCT" -w 2>/dev/null || true)"
+if [[ -z "$blob" ]]; then
+ warn "no keychain entry for service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT — skipping owner identity."
+ warn "Records were still copied. Your dev build will mint a fresh owner key on boot."
+else
+ # The blob is a JSON map; the owner nsec lives under the "identity" key and
+ # MUST be a non-empty JSON string (a number/object/null would be a corrupt
+ # key that boot rejects, falling through to a fresh identity — the exact
+ # split-brain this helper prevents).
+ if have jq; then
+ identity="$(printf '%s' "$blob" | jq -er 'if (.identity | type) == "string" and (.identity | length) > 0 then .identity else empty end' 2>/dev/null || true)"
+ else
+ identity="$(printf '%s' "$blob" | python3 -c 'import json,sys; v=json.load(sys.stdin).get("identity"); print(v if isinstance(v,str) and v else "")' 2>/dev/null || true)"
+ fi
+ if [[ -z "$identity" ]]; then
+ warn "keychain blob has no non-empty string \"identity\" entry — skipping owner identity (records still restored)."
+ else
+ keyfile="$DEV_DIR/identity.key"
+ if [[ $DRY_RUN -eq 1 ]]; then
+ info "[dry-run] write owner nsec → $keyfile (mode 0600, atomic) [secret not shown]"
+ else
+ mkdir -p "$DEV_DIR"
+ # Stage to a 0600 temp, then atomic rename — never truncate the live key
+ # path, so an interruption cannot leave a partial/corrupt identity.key.
+ stage="$(new_stage "$keyfile")"
+ STAGE_TEMPS+=("$stage")
+ printf '%s' "$identity" > "$stage"
+ finalize "$stage" "$keyfile"
+ info "wrote $keyfile (mode 0600) — dev boot adopts it into the dev keyring, then deletes it"
+ fi
+ fi
+fi
+
+# --- step 4: closing checklist ----------------------------------------------
+say "[4/4] Done. Next steps"
+cat < keys buzz-desktop → buzz-desktop-dev).
+ - First boot adopts identity.key into the dev keyring, then deletes it.
+ 2. Worktree launches: worktree-suffixed dev dirs are symlinked to the
+ canonical dev dir by sync_shared_agent_data ONLY when BUZZ_SHARE_IDENTITY=1.
+ If you launch from a worktree, export BUZZ_SHARE_IDENTITY=1 (and
+ BUZZ_PRIVATE_KEY) or the worktree will mint its own duplicate agents.
+EOF
+if [[ "${BUZZ_SHARE_IDENTITY:-}" != "1" ]]; then
+ say " NOTE: BUZZ_SHARE_IDENTITY is not set in this shell — set it for worktree launches."
+fi
+exit 0
From 40f1dac6913d04c87d72610a69ed53bd12377b84 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 18 Aug 2026 22:06:53 +0100
Subject: [PATCH 17/27] Refine mobile pairing confirmation (#6018)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- align the mobile security-code confirmation screen with the Add
Community visual system while keeping the SAS verification flow
unchanged
- use “Confirm desktop code” and a 15% primary-color outline on each
digit box
- support a gitignored, debug-only Android app name and application ID
override for side-by-side device testing; release and profile identities
remain unchanged
## Validation
- `flutter test test/features/pairing/pairing_page_test.dart` (21 tests)
- full mobile pre-push suite (1,469 tests)
- focused Flutter analysis and formatting
- mobile worktree identity contract checks
---------
Signed-off-by: kenny lopez
Signed-off-by: Wes
Co-authored-by: Wes
Co-authored-by: Carl
---
mobile/README.md | 14 +
mobile/android/.gitignore | 1 +
mobile/android/app/build.gradle.kts | 35 ++-
mobile/lib/features/pairing/pairing_page.dart | 276 +++++++++--------
.../features/pairing/pairing_page_test.dart | 282 +++++++++++++++++-
scripts/test-mobile-worktree-overrides.sh | 18 +-
6 files changed, 490 insertions(+), 136 deletions(-)
diff --git a/mobile/README.md b/mobile/README.md
index 6a7f38bdf27..1849e1b097d 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 32f598b0fe5..c21f7b4e86b 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 ca40ee16c69..091ae65262b 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(
+ "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(
- "worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " +
- worktreeIdSuffix,
+ "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/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index 7aabe8aed74..97687c4ed6e 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/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart
index 34b9f4cfb1c..e66e9bb3da4 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
class _ConfirmingSasPairingNotifier extends Notifier
implements PairingNotifier {
- _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false});
+ _ConfirmingSasPairingNotifier({
+ this.sendsIdentityToDesktop = false,
+ this.errorMessage,
+ });
final bool sendsIdentityToDesktop;
+ final String? errorMessage;
+ bool denied = false;
@override
PairingState build() => PairingState(
status: PairingStatus.confirmingSas,
sasCode: '123456',
sendsIdentityToDesktop: sendsIdentityToDesktop,
+ errorMessage: errorMessage,
);
@override
@@ -463,5 +731,5 @@ class _ConfirmingSasPairingNotifier extends Notifier
void setProtectSensitiveActions(bool value) {}
@override
- void denySas() {}
+ void denySas() => denied = true;
}
diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh
index 853d1270db1..dccffd2ef2b 100755
--- a/scripts/test-mobile-worktree-overrides.sh
+++ b/scripts/test-mobile-worktree-overrides.sh
@@ -7,7 +7,8 @@
# display-only label sanitized to [A-Za-z0-9._-].
# - the tracked iOS/Android build files keep production identity, only
# consume the overrides in debug configurations, and let a developer's
-# AppOverrides.xcconfig take precedence over the worktree defaults.
+# AppOverrides.xcconfig / AppOverrides.properties take precedence over the
+# worktree defaults.
# - scripts/mobile-worktree-clean.sh only ever targets suffixed installs,
# never the production app ids.
set -euo pipefail
@@ -157,6 +158,11 @@ grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \
grep -q 'worktreeLabel.matches' "$gradle" \
&& pass "Gradle validates the worktree label before use" \
|| fail "Gradle must validate the worktree label against a safe pattern"
+grep -q 'AppOverrides.properties' "$gradle" \
+ && grep -q 'debugAppName' "$gradle" \
+ && grep -q 'debugIdSuffix' "$gradle" \
+ && pass "Android developer overrides can replace the debug name and identity" \
+ || fail "Gradle must support debug-only AppOverrides.properties"
# Extract a brace-balanced block: everything from the first line matching $2
# to the line where its braces close. Unlike a /start/,/}/ awk range, nested
@@ -181,9 +187,10 @@ printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneaky
|| fail "release-block extractor must not stop at the first nested close brace"
# The worktree suffix/label must only appear inside the debug build type.
-extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{' | grep -q 'worktree' \
- && fail "release build type must not reference worktree identity" \
- || pass "release build type does not reference worktree identity"
+release_block="$(extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{')"
+printf '%s\n' "$release_block" | grep -Eq 'worktree|debugAppName|debugIdSuffix' \
+ && fail "release build type must not reference debug identity overrides" \
+ || pass "release build type does not reference debug identity overrides"
git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \
&& pass "iOS override file is gitignored" \
@@ -191,6 +198,9 @@ git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfi
git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \
&& pass "Android override file is gitignored" \
|| fail "mobile/android/worktree.properties must be gitignored"
+git -C "$repo_root" check-ignore -q mobile/android/AppOverrides.properties \
+ && pass "Android developer override file is gitignored" \
+ || fail "mobile/android/AppOverrides.properties must be gitignored"
grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \
&& pass "just mobile-dev applies the worktree identity" \
|| fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh"
From 78267b0c3a75840d035ff0cc9ad1984def773886 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 18 Aug 2026 23:00:45 +0100
Subject: [PATCH 18/27] Polish mobile message actions (#5873)
## Summary
- Add a lifted-message long-press popover with the reaction tray and
grouped actions.
- Use a native action surface
- Preserve taps and scrolling, exclude attached reactions from the
lifted preview, and align the composition to the bottom safe area.
## Testing
- `bin/just mobile-check`
- `flutter test test/features/channels/message_actions_test.dart`
- `flutter test` (1,364 tests)
- Manual interaction review on iPhone and Pixel 10
---------
Signed-off-by: kenny lopez
Signed-off-by: Kenny Lopez
Signed-off-by: Princess Donut
Signed-off-by: Wes
Co-authored-by: Princess Donut
Co-authored-by: Wes
Co-authored-by: Carl
---
mobile/ios/Runner.xcodeproj/project.pbxproj | 4 +
mobile/ios/Runner/AppDelegate.swift | 22 +
.../Runner/NativeMessageActionSurface.swift | 453 ++++++++
mobile/ios/RunnerTests/RunnerTests.swift | 151 +++
.../channels/channel_detail_page.dart | 11 +
.../channel_detail_page/message_bubble.dart | 290 ++---
.../channel_detail_page/message_list.dart | 6 +
.../compose_bar/compose_bar_widget.dart | 41 +-
.../channels/compose_bar/helpers.dart | 24 +
.../features/channels/message_actions.dart | 168 +--
.../message_action_popover.dart | 999 ++++++++++++++++++
.../message_reaction_tray.dart | 36 +
.../message_actions/quick_reaction_row.dart | 139 +++
.../message_actions/reaction_popover.dart | 177 ++--
.../channels/message_long_press_region.dart | 224 ++--
.../features/channels/thread_detail_page.dart | 12 +
.../channels/thread_detail_page/avatar.dart | 28 +
.../thread_detail_page/thread_message.dart | 316 +++---
.../channels/channel_detail_page_test.dart | 19 +-
.../features/channels/compose_bar_test.dart | 184 ++++
.../channels/message_actions_test.dart | 925 ++++++++++++++++
21 files changed, 3677 insertions(+), 552 deletions(-)
create mode 100644 mobile/ios/Runner/NativeMessageActionSurface.swift
create mode 100644 mobile/lib/features/channels/message_actions/message_action_popover.dart
create mode 100644 mobile/lib/features/channels/message_actions/message_reaction_tray.dart
create mode 100644 mobile/lib/features/channels/message_actions/quick_reaction_row.dart
create mode 100644 mobile/lib/features/channels/thread_detail_page/avatar.dart
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 45cf085cb79..f127990b390 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -16,6 +16,7 @@
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 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* 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 +64,7 @@
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 = ""; };
+ 4A71C00E2F40800100A17E01 /* 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 +190,7 @@
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
+ 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -425,6 +428,7 @@
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,
+ 4A71C00D2F40700100A17E01 /* 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 fa68e539dd0..e8f3d9d1e16 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -10,6 +10,7 @@ import UserNotifications
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
+ private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -113,6 +114,27 @@ import UserNotifications
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.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/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift
new file mode 100644
index 00000000000..4b0d637cedc
--- /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 e1c2ce00f62..e26aa11525c 100644
--- a/mobile/ios/RunnerTests/RunnerTests.swift
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -403,6 +403,157 @@ class RunnerTests: XCTestCase {
}
}
+ func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
+ let actionArguments: [[String: Any]] = [
+ [
+ "id": "reply", "title": "Reply",
+ "symbol": "arrowshape.turn.up.left", "group": "primary",
+ ],
+ [
+ "id": "copyText", "title": "Copy text",
+ "symbol": "doc.on.doc", "group": "utility",
+ ],
+ [
+ "id": "delete", "title": "Delete message",
+ "symbol": "trash", "group": "destructive", "destructive": true,
+ ],
+ ]
+ let definitions = try actionArguments.map { arguments in
+ try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments))
+ }
+
+ XCTAssertEqual(
+ NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
+ [.primary, .utility, .destructive]
+ )
+ XCTAssertEqual(
+ NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
+ 2
+ )
+ XCTAssertEqual(
+ NativeMessageActionSurfaceLayout.preferredHeight(
+ actions: definitions,
+ compatibleWith: UITraitCollection(
+ preferredContentSizeCategory: .large
+ )
+ ),
+ 153
+ )
+ }
+
+ @MainActor
+ func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws {
+ let definition = try XCTUnwrap(
+ NativeMessageActionDefinition(
+ arguments: [
+ "id": "reply", "title": "Reply",
+ "symbol": "arrowshape.turn.up.left", "group": "primary",
+ ]
+ )
+ )
+ var selected = false
+ let row = NativeMessageActionRowControl(
+ definition: definition,
+ foregroundColor: .label,
+ destructiveColor: .systemRed,
+ onSelected: { selected = true }
+ )
+
+ XCTAssertEqual(
+ row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String,
+ UIFont.TextStyle.body.rawValue
+ )
+ XCTAssertNotNil(row.actionImageView.image)
+ row.sendActions(for: .touchUpInside)
+ XCTAssertTrue(selected)
+ }
+
+ @MainActor
+ func testNativeMessageActionRowExpandsForAccessibilityTypography() throws {
+ let traits = UITraitCollection(
+ preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
+ )
+ let definition = try XCTUnwrap(
+ NativeMessageActionDefinition(
+ arguments: [
+ "id": "followThread", "title": "Follow thread",
+ "symbol": "bell", "group": "utility",
+ ]
+ )
+ )
+ let row = NativeMessageActionRowControl(
+ definition: definition,
+ foregroundColor: .label,
+ destructiveColor: .systemRed,
+ compatibleWith: traits,
+ onSelected: {}
+ )
+ let fittingSize = row.systemLayoutSizeFitting(
+ CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height),
+ withHorizontalFittingPriority: .required,
+ verticalFittingPriority: .fittingSizeLevel
+ )
+ row.frame = CGRect(origin: .zero, size: fittingSize)
+ row.layoutIfNeeded()
+ let labelFrame = row.convert(
+ row.actionTitleLabel.bounds,
+ from: row.actionTitleLabel
+ )
+
+ XCTAssertGreaterThan(fittingSize.height, 48)
+ XCTAssertGreaterThan(labelFrame.height, 0)
+ XCTAssertGreaterThanOrEqual(
+ labelFrame.minY,
+ NativeMessageActionSurfaceLayout.rowVerticalPadding
+ )
+ XCTAssertLessThanOrEqual(
+ labelFrame.maxY,
+ fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding
+ )
+ XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0)
+ XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth)
+ }
+
+ @MainActor
+ func testNativeMessageActionSurfaceUsesSystemMaterial() {
+ let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
+ reduceTransparency: false
+ )
+ if #available(iOS 26.0, *) {
+ XCTAssertTrue(effect is UIGlassEffect)
+ XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33)
+ } else {
+ XCTAssertTrue(effect is UIBlurEffect)
+ XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12)
+ }
+ XCTAssertNil(
+ NativeMessageActionSurfaceAppearance.backdropEffect(
+ reduceTransparency: true
+ )
+ )
+ }
+
+ func testNativeMessageActionListDoesNotHideDialogSiblings() {
+ XCTAssertFalse(
+ NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
+ )
+ }
+
+ func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() {
+ XCTAssertEqual(
+ NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"),
+ .dark
+ )
+ XCTAssertEqual(
+ NativeMessageActionSurfaceAppearance.interfaceStyle(from: "light"),
+ .light
+ )
+ XCTAssertEqual(
+ NativeMessageActionSurfaceAppearance.interfaceStyle(from: "system"),
+ .unspecified
+ )
+ }
+
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 6e7449a6333..0e3251c7523 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -138,6 +138,8 @@ class ChannelDetailPage extends HookConsumerWidget {
@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);
@@ -473,6 +475,12 @@ class ChannelDetailPage extends HookConsumerWidget {
composerBottomInset: showsComposer
? composerDockHeight.value
: 0,
+ composerFocusNode: showsComposer
+ ? composerFocusNode
+ : null,
+ restoreComposerFocus: showsComposer
+ ? () => restoreComposerFocus.value?.call()
+ : null,
);
},
),
@@ -521,6 +529,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 87673b3cbe3..8c953b7211b 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,
),
- ),
- if (message.edited) ...[
- const SizedBox(width: Grid.half),
- Text(
- '(edited)',
- style: context.textTheme.labelSmall
- ?.copyWith(
- color:
+ child: Row(
+ children: [
+ Expanded(
+ child: MessageAuthorMeta(
+ displayName: displayName,
+ username: messageUsernameLabel(
+ profile,
+ ),
+ timestamp: formatMessageTime(
+ message.createdAt,
+ ),
+ nameColor: context.colors.onSurface,
+ metadataColor:
context.colors.onSurfaceVariant,
- fontStyle: FontStyle.italic,
+ onAuthorTap: () =>
+ showUserProfileSheet(
+ context,
+ message.pubkey,
+ ),
+ displayNameKey: ValueKey(
+ 'message-author-${message.id}',
+ ),
+ usernameKey: ValueKey(
+ 'message-username-${message.id}',
+ ),
+ timestampKey: ValueKey(
+ 'message-timestamp-${message.id}',
+ ),
),
- ),
- ],
- ],
- ),
- ),
- 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,
),
+ 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,
+ ),
+ ),
+ );
+ },
+ 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 59e4c746fb8..251e40e2fa4 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -15,6 +15,8 @@ class _MessageList extends HookConsumerWidget {
final bool isArchived;
final double appBarTitleContentHeight;
final double composerBottomInset;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
const _MessageList({
required this.entries,
@@ -31,6 +33,8 @@ class _MessageList extends HookConsumerWidget {
required this.isArchived,
required this.appBarTitleContentHeight,
required this.composerBottomInset,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
@@ -804,6 +808,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 c6a26cc59cd..344972f10e0 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();
- useEffect(
- () =>
- () => androidImeFallbackTimer.value?.cancel(),
- [androidImeFallbackTimer],
- );
+ final ownedFocusNode = useFocusNode();
+ final focusNode = this.focusNode ?? ownedFocusNode;
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 09d7a8e8476..78d31e58198 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/message_actions.dart b/mobile/lib/features/channels/message_actions.dart
index e09f9478a70..da2050802e3 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 00000000000..350afff82b3
--- /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 00000000000..21281789045
--- /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 00000000000..15a71c30fc9
--- /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 dfbc127861e..11ea608b70a 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,76 +141,111 @@ 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,
- ),
+ trayWidth: trayWidth,
+ scaleAlignment: trayScaleAlignment,
+ message: message,
+ pageContext: pageContext,
+ pageRef: pageRef,
+ onSelected: (result, effect) => select(result, effect),
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
+
+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,
),
),
- 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,
- ),
- ),
- ),
- ),
- ),
- );
- },
),
),
- ],
+ ),
);
},
);
diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart
index d894e4a035b..caf84815a35 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 setSourceHidden(bool hidden) {
+ if (!context.mounted) return;
+ sourceHidden.value = hidden;
+ }
- void handlePointerEnd(PointerEvent event) {
- if (event.pointer == activePointer.value) cancel();
+ 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 6ab3ddd9b63..007702b5a09 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -43,6 +43,7 @@ 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';
/// Full-screen thread detail page.
///
@@ -72,6 +73,8 @@ 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
@@ -635,6 +638,9 @@ class ThreadDetailPage extends HookConsumerWidget {
isMember: isMember,
isArchived: isArchived,
isThreadHead: true,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
Padding(
padding: const EdgeInsets.symmetric(
@@ -714,6 +720,9 @@ class ThreadDetailPage extends HookConsumerWidget {
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
if (nestedSummary != null)
_NestedThreadSummaryRow(
@@ -750,6 +759,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 00000000000..502d6cffa8d
--- /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 248ccc96812..db7d1ba0b42 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,7 +83,12 @@ 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,
);
}
@@ -101,151 +111,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,
),
- ),
- if (message.edited) ...[
- const SizedBox(width: Grid.half),
- Text(
- '(edited)',
- style: context.textTheme.labelSmall
- ?.copyWith(
- color:
+ child: Row(
+ children: [
+ Expanded(
+ child: MessageAuthorMeta(
+ displayName: displayName,
+ username: messageUsernameLabel(
+ profile,
+ ),
+ timestamp: formatMessageTime(
+ message.createdAt,
+ ),
+ nameColor: context.colors.onSurface,
+ metadataColor:
context.colors.onSurfaceVariant,
- fontStyle: FontStyle.italic,
+ 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}',
+ ),
),
- ),
- ],
- ],
- ),
- ),
- 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,
),
+ 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,
+ ),
+ ),
+ );
+ },
+ 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 +288,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/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 9b9ac596c7f..826a419d8da 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -1478,8 +1478,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);
});
@@ -6214,7 +6225,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,
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index 1270bc386c9..775c9f4f719 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/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart
index 842a97211b5..a8c44e34923 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);
From 6ea7a2b2211438359b227a9991cf8ccad2927fe2 Mon Sep 17 00:00:00 2001
From: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:09:58 -0400
Subject: [PATCH 19/27] fix(desktop): preserve early relay auth challenges
(#3320)
## Summary
- buffer native WebSocket frames until `plugin:websocket|connect`
returns the socket ID and the NIP-42 auth waiter is installed
- drain those frames in order before normal inbound handling begins
- add a deterministic E2E seam and regression for an AUTH challenge
delivered before connect resolves
## Root cause
The native WebSocket task starts forwarding relay frames before the
connect command returns its socket ID. Buzz relay sends the NIP-42 AUTH
challenge immediately on connection, so Desktop could process that
challenge while `wsId` and `authRequest` were still unset. The challenge
was discarded, the cold connection failed authentication, and the first
plain-text send failed. Channel history still appeared because it loads
through the Tauri channel-window command rather than this frontend
WebSocket.
## Verification
- deterministic regression is red before the fix (`connecting` after 5s)
and green after it
- `pnpm test`: 3,721 passed
- `pnpm build:e2e`: passed
- focused early-AUTH E2E: passed
- existing failed-initial-dial E2E: passed
- `pnpm typecheck`: passed
- Biome + file-size pre-commit checks: passed
The complete 7-test relay-reconnect file passed once before the final
line-count-only compaction. After that compaction, two full-file reruns
each had the same unrelated startup-seam flake in the existing
initial-dial test; both the new test and that existing test pass
independently at the committed HEAD.
---------
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
---
desktop/src/shared/api/relayAuthPolicy.ts | 22 ++++++++
desktop/src/shared/api/relayClientSession.ts | 54 +++++++++----------
.../shared/api/relayInboundBuffer.test.mjs | 46 ++++++++++++++++
desktop/src/shared/api/relayInboundBuffer.ts | 36 +++++++++++++
desktop/src/testing/e2eBridge.ts | 22 +++++++-
desktop/tests/e2e/relay-reconnect.spec.ts | 51 ++++++++++++++++--
desktop/tests/helpers/bridge.ts | 4 ++
7 files changed, 202 insertions(+), 33 deletions(-)
create mode 100644 desktop/src/shared/api/relayInboundBuffer.test.mjs
create mode 100644 desktop/src/shared/api/relayInboundBuffer.ts
diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts
index 2d76e0c3f7d..6e100fe7b46 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 29b24f21d1f..5e7f5c06e30 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 00000000000..9add519d730
--- /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 00000000000..e4291e5ecca
--- /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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1aa98ca4a7f..8da233dfcef 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
@@ -3062,6 +3066,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 +9762,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 +10258,7 @@ export function maybeInstallE2eTauriMocks() {
mockAuthResponses.length = 0;
mockChannelHistoryCloses.length = 0;
relayWebsocketConnectAttemptStarts.length = 0;
+ mockAuthSigningAttempts = 0;
deferredSendMessageLiveEchoes.length = 0;
deferredLinkPreviewMetadataQueue = [];
deferredLinkPreviewUploadQueue = [];
@@ -13131,6 +13142,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/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts
index 74f819cc51a..ef5812c9e87 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/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 815c362fc10..dbe8c43323b 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
From e2ade93f02f6d1b4db23e0c442a2c65608e54d36 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Tue, 18 Aug 2026 18:18:48 -0400
Subject: [PATCH 20/27] fix(desktop): downscale large avatars for agent-share
PNG body (#6260)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sharing an agent encodes a `.agent.png` snapshot with the avatar as the
PNG image body. Non-PNG avatars were transcoded at full resolution and
already-PNG avatars were carried through unchanged, so a large source
avatar could produce a PNG that exceeds the 10 MiB
`MAX_SNAPSHOT_PNG_BYTES` cap and fail the send. A 2764×4096 webp avatar
encoded to ~26 MB — over 2.5× the ceiling — which is why sharing that
agent hung for a few seconds (fetching and transcoding the image) and
then failed.
The share dialog compounded the problem: on failure it discarded the
pipeline's real error (`Snapshot exceeds the 10 MiB size limit…`) and
toasted a generic `Couldn't send … Try again.`, hiding the actual cause.
## Changes
- **Downscale the avatar for the snapshot PNG body.** The body is only a
card thumbnail, so raster avatars are now downscaled to a 512px longest
edge (`MAX_PNG_BODY_EDGE`) before PNG re-encoding, mirroring the
frontend SVG rasterizer's 512×512 cap in `snapshotAvatarPng.ts`.
Already-PNG avatars over the dimension cap route through the same
downscaling transcode path instead of a straight tEXt-chunk injection.
The manifest's `avatar_url`/`avatar_data_url` source reference is
untouched — only the PNG image body is downscaled.
- **Surface the real error in the share dialog.** `PersonaShareDialog`
(used by `SnapshotShareDialog`/`TeamShareDialog`) and
`AgentCardViewerDialog` now toast the send controller's actual error
message, falling back to the generic string only when it is empty. A
`getCurrentError()` accessor reads the error through a ref because the
render-captured `state.error` is stale in the closure immediately after
`beginSend` resolves.
A unit test pins the invariant: an oversize (2764×4096) avatar produces
a snapshot that stays far under the cap with body dimensions clamped to
512px.
---------
Signed-off-by: Will Pfleger
Signed-off-by: Duncan
Co-authored-by: Duncan
---
.../src/managed_agents/agent_snapshot.rs | 41 +++++++++++++-
.../managed_agents/agent_snapshot_tests.rs | 55 ++++++++++++++++++-
.../agents/ui/AgentCardViewerDialog.tsx | 5 +-
.../features/agents/ui/PersonaShareDialog.tsx | 5 +-
.../agents/ui/useSnapshotSendController.ts | 24 ++++++--
desktop/tests/e2e/agents.spec.ts | 10 +++-
6 files changed, 130 insertions(+), 10 deletions(-)
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
index 5b51c522551..4b734ce1591 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_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
index de79df92e84..9f234749bc9 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
@@ -234,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/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
index c2422466e47..b47cee5e3c5 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/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx
index 5cf4f9ea3ba..13eae7971b2 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/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts
index e14481b7502..d7e4daca2c0 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/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index e191d293045..346d1e088ea 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -2287,7 +2287,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 +2334,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);
From 6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5 Mon Sep 17 00:00:00 2001
From: Taksh Kothari
Date: Wed, 19 Aug 2026 03:49:05 +0530
Subject: [PATCH 21/27] fix(desktop): emit camelCase config-write payload
fields (#6062)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes #6015.
`ConfigWriteMechanism` is internally tagged and carried only `rename_all
= "camelCase"`. On an internally tagged enum that renames the
**variants**, never the variants' fields, so the payload went out
snake_case:
```
{"type":"respawnWithEnvVar","env_key":"K"}
{"type":"acpSetConfigOption","config_id":"c"}
{"type":"acpSetSessionModel"}
{"type":"gooseNativeConfigWrite","config_key":"g"}
{"type":"readOnly"}
```
against `envKey` / `configId` / `configKey` in
`desktop/src/shared/api/types.ts:615-620`. That output is a probe run of
the real module before the fix, not a reading of the code.
What makes it read as correct is the asymmetry: the variant *names*
rename fine, so the `type` discriminant and every `switch
(writeVia.type)` behave; and the enclosing `NormalizedField`'s own
fields (`writeVia`, `overriddenValue`, `isRequired`) rename fine too,
because `rename_all` does apply to struct fields. Only the variant's
field is wrong.
Adding `rename_all_fields = "camelCase"` fixes it. `rename_all_fields`
appeared zero times in `desktop/src-tauri` before this.
**Severity, stated plainly: latent, not currently user-visible.**
Nothing in `desktop/src/**` reads `.envKey`/`.configId`/`.configKey` off
a `writeVia` — `AgentConfigPanel.tsx` is the only `RuntimeConfigSurface`
consumer and never touches the field, and no Rust code deserializes the
type either. The write-back path these fields exist for is not wired
yet. The hazard is for whoever wires it: `invokeTauri` is an
unchecked cast, so they get `undefined` with a green `tsc`.
I also carried the attribute onto `ConfigFieldType`. Its only payload
field is `options`, single-word, so that half is not a fix — it is the
attribute the next multi-word field would silently need.
One divergence from the issue's suggested step 3: the 20 `e2eBridge.ts`
sites already emit camelCase, and camelCase is the contract, so they are
correct as written — changing them would have been wrong. What they
lacked was provenance, since agreeing with `api/types.ts` while the
backend emitted something else is exactly what let this sit. They now
name the Rust test that pins the bytes.
**Tests** (`wire_format_tests`, 4 cases, whole-value not key-set — a
key-set assertion still passes when a variant name regresses):
- every variant against the TypeScript spelling;
- the nested `NormalizedField`, which is the shape the renderer actually
receives;
- a camelCase round-trip **plus** an assertion that the old `env_key`
spelling is now rejected, so a revert cannot quietly keep deserializing;
- `ConfigFieldType::Enum`.
Removing `rename_all_fields` again turns three of the four red.
Verified locally: full Tauri library suite 2444 passed / 15 ignored / 0
failed; `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml
--workspace --all-targets -- -D warnings` clean; `cargo fmt
--manifest-path desktop/src-tauri/Cargo.toml --all -- --check`; in
`desktop/`: `pnpm typecheck`, `pnpm check`, `pnpm test` 4954 passed;
`git diff --check`. Not run: the app itself — there is no UI path to
this field yet, which is the same reason the bug is latent.
---------
Signed-off-by: Taksh
---
.../src/managed_agents/config_bridge/types.rs | 117 +++++++++++++++++-
desktop/src/testing/e2eBridge.ts | 6 +
2 files changed, 122 insertions(+), 1 deletion(-)
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 3842825fe8e..d96736fb69c 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 },
@@ -263,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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 8da233dfcef..1f7b1477494 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -1817,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",
From 7e2651791d598a3938ef4560a41801223fb9b2c9 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 18 Aug 2026 23:24:09 +0100
Subject: [PATCH 22/27] Add font size and conversation density preferences
(#5644)
## Summary
- Add separate **Font size** and **Conversation density** controls in
Appearance.
- Use a 13 / 14 / 15px conversation text ramp for Smaller / Default /
Larger while scaling interface typography through a shared virtual-rem
system.
- Keep layout geometry fixed while typography changes, and compose Cmd
+/- text zoom on top of the selected preference.
- Let Compact, Comfy, and Spacious control conversation row spacing plus
paragraph and list rhythm in Markdown content across Buzz.
- Preview font size and density together with click-and-drag comparison;
interrupted scrubbing restores the saved preference.
- Synchronize preferences across open Buzz windows, including full
local-storage clears during sign-out or dev reset.
- Promote the segmented control to shared UI and make Settings rows
adapt to their card width.
## Validation
- `just ci`
- 4,965+ desktop unit tests across the final merged branch
- 1,465 mobile tests through the full repository gate
- Focused Playwright coverage for font-size/density independence, drag
preview cancellation, Inbox geometry, thread rendering, keyboard zoom,
and cross-surface typography
- Fresh E2E build against the final merged branch
---------
Signed-off-by: kenny lopez
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Princess Donut
Co-authored-by: Princess Donut
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
---
AGENTS.md | 17 +-
desktop/src/app/useWebviewZoomShortcuts.ts | 17 +-
.../src/features/home/ui/InboxListPane.tsx | 4 +-
.../src/features/home/ui/InboxMessageRow.tsx | 24 +-
.../messages/lib/useRichTextEditor.ts | 2 +-
.../src/features/messages/ui/DiffViewer.css | 6 +-
.../features/messages/ui/MessageHeader.tsx | 5 +-
.../src/features/messages/ui/MessageRow.tsx | 20 +-
.../features/messages/ui/MessageTimestamp.tsx | 46 +-
.../ui/AppearanceSettingsControls.tsx | 185 ++++++-
.../settings/ui/SettingsOptionGroup.tsx | 3 +-
.../features/settings/ui/SettingsPanels.tsx | 49 +-
desktop/src/main.tsx | 7 +-
desktop/src/shared/lib/cn.ts | 16 +-
.../conversationDensityPreference.test.mjs | 80 +++
.../lib/conversationDensityPreference.ts | 101 ++++
.../shared/lib/fontSizePreference.test.mjs | 97 ++++
desktop/src/shared/lib/fontSizePreference.ts | 121 +++++
desktop/src/shared/styles/globals.css | 1 +
.../shared/styles/globals/avatar-framing.css | 4 +-
.../src/shared/styles/globals/components.css | 2 +-
.../src/shared/styles/globals/composer.css | 3 +-
.../src/shared/styles/globals/terminal.css | 4 +-
.../src/shared/styles/globals/typography.css | 56 ++
desktop/src/shared/ui/markdown.tsx | 4 +-
desktop/src/shared/ui/segmented-control.tsx | 218 ++++++++
desktop/tailwind.config.js | 54 +-
desktop/tests/e2e/agents.spec.ts | 2 +
.../tests/e2e/buzz-theme-screenshots.spec.ts | 514 +++++++++++++++++-
.../e2e/entity-link-recipient-cards.spec.ts | 4 +-
.../e2e/inbox-refactor-screenshots.spec.ts | 225 +++++++-
desktop/tests/e2e/mobile-pairing-qr.spec.ts | 10 +-
desktop/tests/e2e/profile.spec.ts | 99 +++-
.../e2e/top-chrome-zoom-clearance.spec.ts | 21 +-
34 files changed, 1891 insertions(+), 130 deletions(-)
create mode 100644 desktop/src/shared/lib/conversationDensityPreference.test.mjs
create mode 100644 desktop/src/shared/lib/conversationDensityPreference.ts
create mode 100644 desktop/src/shared/lib/fontSizePreference.test.mjs
create mode 100644 desktop/src/shared/lib/fontSizePreference.ts
create mode 100644 desktop/src/shared/styles/globals/typography.css
create mode 100644 desktop/src/shared/ui/segmented-control.tsx
diff --git a/AGENTS.md b/AGENTS.md
index 3a79c4b38d8..e10e49e664f 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/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts
index cda6c0f2ede..e8b93207945 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/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx
index b83a19a1673..f2d4421081d 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 (
{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/MessageTimestamp.tsx b/desktop/src/features/messages/ui/MessageTimestamp.tsx
index ae7d4ba4f23..7d7072fd3ee 100644
--- a/desktop/src/features/messages/ui/MessageTimestamp.tsx
+++ b/desktop/src/features/messages/ui/MessageTimestamp.tsx
@@ -5,7 +5,14 @@ import {
} from "@/features/messages/lib/dateFormatters";
import { cn } from "@/shared/lib/cn";
import { formatItemTimestamp } from "@/shared/lib/datetime";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/shared/ui/tooltip";
+
+const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
/**
* The timestamp beside a message author, and the clock that fades in over the
@@ -43,21 +50,26 @@ export function MessageTimestamp({
: formatItemTimestamp(createdAt, { withTime: true });
return (
-
-
-
- {displayTime}
-
-
-
- {formatFullDateTime(createdAt)}
-
-
+
+
+
+
+ {displayTime}
+
+
+
+ {formatFullDateTime(createdAt)}
+
+
+
);
}
diff --git a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx
index 6e3d1604531..ea8a1dc97e8 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 72b292c447c..29638dd3b49 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 b509a70e4e1..6b00fc8f74c 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/main.tsx b/desktop/src/main.tsx
index 1520814559f..634a9b171a1 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client";
import { App } from "@/app/App";
import { RootErrorBoundary } from "@/app/RootErrorBoundary";
import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog";
-import "@fontsource-variable/inter/wght.css";
+import "@fontsource-variable/inter/opsz.css";
+import "@fontsource-variable/inter/opsz-italic.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/700.css";
import "@/shared/styles/globals.css";
@@ -19,6 +20,8 @@ import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
+import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
+import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";
type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
@@ -123,6 +126,8 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
+ initializeConversationDensityPreference();
+ initializeFontSizePreference();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
diff --git a/desktop/src/shared/lib/cn.ts b/desktop/src/shared/lib/cn.ts
index a5ef193506d..79fe7d2897e 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 00000000000..2b5391f9277
--- /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 00000000000..309a6c8d0e7
--- /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 00000000000..217a3e5a00a
--- /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 00000000000..9604a2c2cbc
--- /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 0d5a1032191..53a753fd9e6 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 44c11163fd1..79a2e2ad072 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 f206539a937..7e0e2fe116d 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 0af4a59b4f1..2ee690e41de 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 544f0ae2efe..c5aa001afb0 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 00000000000..fa59d372411
--- /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/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index a556278eade..6326709d49f 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/segmented-control.tsx b/desktop/src/shared/ui/segmented-control.tsx
new file mode 100644
index 00000000000..78cd280bae9
--- /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/tailwind.config.js b/desktop/tailwind.config.js
index 8905fc16cea..07d00b0db92 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 346d1e088ea..b81c5889f3a 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);
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index 2bcd36908eb..0c01bfd6602 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/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts
index 75afe47f3b1..b373092a818 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 15a804d71f6..8f1f074e277 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 937fd57b473..731cce17e56 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 6c88b628ce8..a58859ec55f 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/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts
index eb69953dbd6..d26001ba6ef 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,
From 121e4b3ce7acab6ac310257f444997f58a97cb2e Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Tue, 18 Aug 2026 18:26:35 -0400
Subject: [PATCH 23/27] fix(desktop): exclude archived agents from nest, order
regeneration (#5905)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The managed "Active Agents" table in `~/.buzz/AGENTS.md` was rendered
from every managed-agent record with no filtering, so archived duplicate
instances kept appearing under the active relay's header. This scopes
the roster to identity-active agents and makes regeneration safe under
concurrency.
## Roster filter: identity-archive only
`render_dynamic_section` now drops only records whose pubkey is present
in the relay's `kind:13535` archive snapshot. Local records can't tell
they're archived — they all carry `is_active: true` (that flag is a
*definition*-archive, not an identity-archive), so archive truth lives
only relay-side. The read **fails open**: an unreachable relay yields an
empty set and hides no one.
There is deliberately **no** relay-scope filter. `relay_url` is a legacy
creation-era field that `effective_agent_relay_url()` ignores — every
agent is eligible on every community, and snapshot-imported records
store `relay_url: ""` by design. Filtering on it would hide valid,
runnable agents after a workspace switch or import. Foreign-relay relic
records leave the table via record deletion, not code.
## Regeneration on archive / unarchive
`archive_identity` and `unarchive_identity` submitted the relay event
and returned without refreshing `AGENTS.md`, unlike the ~20 other
mutation sites that call `try_regenerate_nest`. A just-archived agent
therefore lingered on the roster until an unrelated edit or the next
launch. Both commands now trigger a regeneration after a successful
submit.
Regeneration is bound through a `NestRegenTrigger` trait rather than
constructing the `try_regenerate_nest` callback at the Tauri-command
delegation site. The command cores take `regen: &impl NestRegenTrigger`
and own the `|| regen.trigger()` binding; the thin wrappers only pass
`&app` (whose `impl` calls `try_regenerate_nest`). This puts the regen
wiring inside the unit-tested core — a `CountingRegen` double proves
each core fires exactly one regeneration — instead of an untestable seam
where a wrapper could silently lose the refresh while the suite stayed
green. The regen races the relay's `kind:13535` snapshot update, so it's
best-effort and fail-open — a stale render self-heals on the next cycle.
## Ordered regeneration
`try_regenerate_nest` previously spawned unconstrained tasks that each
snapshotted state, awaited two relay requests, then wrote — so a slow
pre-edit generation could overwrite a newer one. Boot made this
deterministic: the boot regen races the `apply_workspace` regen, and the
fallback-relay render could finish last.
`NestRegenGate` fixes this with a single `highest_requested` watermark.
A monotonic generation is claimed *synchronously* at request time
(encoding call order) and advances the watermark under one lock; the
spawned task carries its generation and, at commit, reads the watermark
under that same lock — the compare-and-write is atomic with no await
held across it. A generation whose number is below the current watermark
drops its result instead of rolling the file back.
Gating on highest-*requested* rather than highest-*written* is the
load-bearing choice: if a newer generation is requested but then fails
its relay reads, an older in-flight generation must **not** publish its
now-obsolete roster. Behavior delta: once a newer regeneration has been
requested, no older generation will ever write; if that newer generation
fails, the file is left as-is and self-heals on the next trigger rather
than regressing to a stale snapshot. This is an ordered,
latest-request-wins gate — not a work coalescer: superseded generations
still perform their relay reads and drop the result at commit time.
The gate's commit lock is acquired with a poison-to-`io::Error` mapping
rather than `expect()`, so a poisoned lock degrades to the same
warn-and-continue path as any other commit failure instead of panicking
the desktop process (a best-effort housekeeping write must never take
down the app).
## One relay target per regeneration
A regeneration read the workspace relay override three times — the
NIP-11 signer in `fetch_relay_self`, the snapshot query in
`query_relay`, and the rendered footer — so a workspace switch
mid-flight could pair one relay's advertised signer with another relay's
snapshot, fail open, and render archived agents as active.
`capture_relay_target` now resolves the effective relay (WebSocket +
HTTP API base) once, before any network work, and
`fetch_archived_pubkeys_at` threads that single target through both the
NIP-11 fetch and the `/query`; the footer renders the same target.
Signer, snapshot, and footer always belong to one relay.
## Monotonic archival snapshot publishing
`publish_nipia_archival_list` stamped its `kind:13535` snapshot with a
whole-second `created_at`. A rapid archive→unarchive within the same
second produced two events whose NIP-16 replaceable-event tie-break
(higher event id wins) could strand the older, stale archive state as
canonical. Publishing now uses a bounded retry that re-reads current
archive state and rebuilds the snapshot each attempt, so the published
list reflects the latest intended state rather than a fixed same-second
race loser. After the attempt budget (8) is exhausted the publisher
`bail!`s; the sole caller treats that as a `warn!` side effect and
continues, matching the surrounding best-effort submit path.
## Test-file split
The renderer, `upsert_managed_section`, and the regeneration-gate tests
moved to `nest/render_tests.rs` so each test file stays under the
repository's 1000-line ratchet.
---------
Signed-off-by: Will Pfleger
Signed-off-by: Wes
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Wes
Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Co-authored-by: Duncan
---
.../src/handlers/identity_archive.rs | 190 +++++
.../buzz-relay/src/handlers/side_effects.rs | 153 +++-
.../src/commands/identity_archive.rs | 470 ++++++++++--
desktop/src-tauri/src/egress_guard_tests.rs | 4 +
desktop/src-tauri/src/managed_agents/nest.rs | 185 ++++-
.../src/managed_agents/nest/render_tests.rs | 713 ++++++++++++++++++
.../src/managed_agents/nest/tests.rs | 410 ----------
desktop/src-tauri/src/relay.rs | 2 +-
8 files changed, 1637 insertions(+), 490 deletions(-)
create mode 100644 desktop/src-tauri/src/managed_agents/nest/render_tests.rs
diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs
index 9da920483fe..40c54647837 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 282ea776577..f2b58937ab5 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();
+
+ // 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}"))?,
+ );
+ }
- let mut tags: Vec = Vec::with_capacity(archived.len() + 1);
- tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {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);
- 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)
+ .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 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}"))?;
+ 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/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs
index d15ee82abc3..0cc5679bf7b 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>,
+/// 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 auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?;
- let auth_ref = auth_tag.as_ref();
+ 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/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs
index 1513742beaf..0c2a9573af6 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/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs
index a57676f0a97..72cf4664272 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 00000000000..ed4ee2c1f9b
--- /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 672094d6c2b..bc67a5b69eb 100644
--- a/desktop/src-tauri/src/managed_agents/nest/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs
@@ -422,416 +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,
- 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, "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/relay.rs b/desktop/src-tauri/src/relay.rs
index 685f83b7999..69b7b9ab1f4 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()
From 359fe646758d253ee94bf054a87904efd1dce7d1 Mon Sep 17 00:00:00 2001
From: klopez4212
Date: Tue, 18 Aug 2026 23:32:19 +0100
Subject: [PATCH 24/27] Refine the mobile emoji picker (#5853)
## Summary
- use a native detented emoji sheet on iOS that opens at two-thirds and
keeps scrolling active at every height
- align the search, close control, background, and full-width category
row while retaining the Flutter tray on Android
- add a persisted desktop-style skin-tone selector and show one selected
variant per emoji
## Testing
- `just ci` gates completed, with the disk-heavy stages resumed
individually after generated artifacts filled the worktree volume
- full mobile suite: 1,360 tests passed
- focused picker/composer/reaction suite: 145 tests passed
- signed iOS Release build succeeded
- Android debug build installed and launched on Pixel 10
Snapshots are attached in a PR comment.
---------
Signed-off-by: kenny lopez
Signed-off-by: Watcher
Signed-off-by: Kenny Lopez
Signed-off-by: Wes
Co-authored-by: Watcher
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut
Co-authored-by: Wes
Co-authored-by: Carl
---
mobile/ios/Runner.xcodeproj/project.pbxproj | 20 +-
mobile/ios/Runner/AppDelegate.swift | 8 +
mobile/ios/Runner/NativeEmojiPicker.swift | 193 +++++
.../ios/Runner/NativeEmojiPickerModel.swift | 437 ++++++++++
mobile/ios/Runner/NativeEmojiPickerView.swift | 596 ++++++++++++++
mobile/ios/RunnerTests/RunnerTests.swift | 225 +++++
.../lib/features/channels/emoji_picker.dart | 212 ++++-
.../channels/emoji_picker/category_rail.dart | 117 +++
.../channels/emoji_picker/emoji_grid.dart | 21 +-
.../emoji_picker/ios_native_picker.dart | 191 +++++
.../channels/emoji_picker/search_field.dart | 124 +--
.../features/channels/emoji_picker_test.dart | 770 +++++++++++++++++-
12 files changed, 2780 insertions(+), 134 deletions(-)
create mode 100644 mobile/ios/Runner/NativeEmojiPicker.swift
create mode 100644 mobile/ios/Runner/NativeEmojiPickerModel.swift
create mode 100644 mobile/ios/Runner/NativeEmojiPickerView.swift
create mode 100644 mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index f127990b390..f6f66ee2dc2 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -16,7 +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 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.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 */; };
@@ -64,7 +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 = ""; };
- 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.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 = "