diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a383c6d8164..e5e84504cee 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/.release/desktop-candidate.json b/.release/desktop-candidate.json
index e668efa7b2a..3920dfeb44f 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.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/AGENTS.md b/AGENTS.md
index 542606627fb..efd0e534634 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -200,7 +200,7 @@ place.
| `.github/workflows/deploy-aws.yml` | new | Continuous deployment of the relay to AWS on every push to `main`. Runs after `docker.yml` via `workflow_run`, authenticates by OIDC (no stored keys), and applies Terraform with the commit's immutable `:sha-<7>` image |
| `desktop/src-tauri/src/relay/allowlist.rs` | new | Single-relay host allowlist. Upstream is multi-community by design; this fork ships a client that reaches only `relay.bitcoinmarkets.app`. **Lives under `relay/`, not at the crate root** — see the `relay.rs` row |
| `desktop/src-tauri/src/native_websocket.rs` | allowlist call in `open_connection` | The transport is the one path every relay session takes, so a host restriction there cannot be bypassed from the UI |
-| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red `Desktop Core`, not a merge conflict.** The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within |
+| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red check, not a merge conflict.** Upstream #6187 (2026-08-19 sync) made the file-size policy a first-class gate: it is now `just file-size-check`, run repository-wide as the **`File size policy`** step of the `scripts` CI job, and it no longer hangs off the per-surface `desktop`/`web`/`mobile` path filters. So an overflow here fails on every PR regardless of which paths it touched, and it surfaces under `scripts` rather than `Desktop Core` — run `just file-size-check` locally to reproduce. The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within |
| `mobile/lib/shared/relay/relay_allowlist.dart` | new | Mobile counterpart. Skips enforcement under `flutter test` (`FLUTTER_TEST`) because upstream tests use `wss://relay.example.com`; editing those 13 files would be a large permanent conflict surface |
| `mobile/lib/shared/relay/relay_socket.dart` | allowlist call in `connect()` | Transport choke point, as on desktop |
| `mobile/lib/shared/relay/relay_validation.dart` | allowlist call after the shape checks | One hunk covers all four invite/deep-link call sites; placed after the existing checks so malformed input keeps its original error |
@@ -215,7 +215,7 @@ place.
| `desktop/src-tauri/Info.plist` | `CFBundleDisplayName`, `CFBundleName` and the three `NS*UsageDescription` strings → `BitcoinMarkets` | `productName` only renames the `.app` directory, the DMG and the mounted volume. These keys are what macOS displays: Finder reads `CFBundleDisplayName`, the menu bar reads `CFBundleName`, and the usage descriptions are quoted verbatim in system permission prompts. Verified against a built canary before patching — the bundle was `BitcoinMarkets.app` while `CFBundleName` was still `Buzz`, so the app asked for the microphone as "Buzz". `CFBundleIdentifier` and the `buzz-desktop` executable name stay |
| `mobile/ios/Runner/Info.plist` | `CFBundleName` and the three `NS*UsageDescription` strings → `BitcoinMarkets` | The xcconfigs below set `CFBundleDisplayName` (home-screen label); `CFBundleName` is the shorter name iOS falls back to in Settings, and it was still `Buzz`. Usage descriptions appear verbatim in iOS permission prompts |
| `mobile/ios/Flutter/Debug.xcconfig`, `Release.xcconfig` | `APP_DISPLAY_NAME = BitcoinMarkets` | iOS home-screen name, debug and release |
-| `mobile/android/app/build.gradle.kts` | `app_name` resValue → `BitcoinMarkets`, in `defaultConfig` and the worktree-debug branch | Android launcher label. Two hunks because the worktree label composes onto the same string |
+| `mobile/android/app/build.gradle.kts` | `app_name` resValue → `BitcoinMarkets`, in `defaultConfig` and the worktree-debug branch | Android launcher label. Two hunks because the worktree label composes onto the same string. **Upstream now writes the same resource from a third place**: #6049's `debugAppName` (read from the override file's `appName` property) was extended in the 2026-08-19 sync into an `if (debugAppName != null) … else if (worktreeLabel != null)` chain, so upstream's branch runs *before* the fork's. That conflicts every time upstream touches the chain, and the resolution is *take upstream's new branch, keep the fork's brand in the fallback* — never replace the fallback with upstream's `"Buzz ($worktreeLabel)"`. The fork's literal is only reachable when no explicit `appName` override is set, which is the normal worktree case |
| `scripts/mobile-worktree-overrides.sh` | branch-labelled debug name | Generates the gitignored per-worktree `APP_DISPLAY_NAME` |
| `scripts/test-mobile-worktree-overrides.sh` | assertions derive the production name from `mobile-worktree-overrides.sh` instead of matching the literal `Buzz` | Four assertions hardcoded `Buzz` and **failed CI on `main` for three commits** after the rename (`ff5e83c28`…`684a15f50`), cascading into `Desktop` and `Desktop E2E Integration` through their gate steps. Deriving the name tests the contract the file is for — release unlabelled, debug labelled, iOS and Android agreeing — so a future rename cannot fail it for the wrong reason |
@@ -1075,14 +1075,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.
@@ -1449,11 +1449,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
@@ -1539,10 +1546,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/CHANGELOG.md b/CHANGELOG.md
index d22b60d6745..22844fcda39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
# 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
+
+- 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/Cargo.lock b/Cargo.lock
index f6d98a44d53..312f99c195d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3555,9 +3555,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",
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/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/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.
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/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,
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/package.json b/desktop/package.json
index 881f8d70fce..a1fca69b293 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "buzz",
"private": true,
- "version": "0.5.15",
+ "version": "0.5.17",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 2bfa8315252..dc10343cc88 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1084,7 +1084,7 @@ dependencies = [
[[package]]
name = "buzz-desktop"
-version = "0.5.15"
+version = "0.5.17"
dependencies = [
"anyhow",
"arboard",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 6d14b04cf4c..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.15"
+version = "0.5.17"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
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/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 b97cefc0ee8..976519a076b 100644
--- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs
@@ -9,6 +9,41 @@ use crate::{
const RELAY_DIRECTORY_PAGE_SIZE: usize = 500;
const RELAY_FILTER_BATCH_SIZE: usize = 10;
+/// Per-rebuild ceiling on directory-rebuild `/query` requests in flight at once.
+/// The rebuild fans dozens of exact-author batches across the relay; issuing
+/// them serially dominated agent-mention send latency (~6 s for ~100
+/// candidates). A bounded window collapses that to a few round trips while
+/// keeping the request rate well under the relay's admission gate, which
+/// back-pressures any 429 anyway. Each rebuild builds one semaphore and shares
+/// it across every phase, so a single rebuild's runtime-directory and
+/// owner-profile phases — which run concurrently under one `try_join!` — never
+/// exceed it together. (Overlapping rebuilds each hold their own budget.)
+const RELAY_DIRECTORY_MAX_CONCURRENCY: usize = 8;
+
+/// Run one `query_relay` request per `RELAY_FILTER_BATCH_SIZE` chunk of
+/// `filters`, each acquiring a permit from `semaphore` so the total in-flight
+/// request count stays within the shared ceiling even when several batch sets
+/// run concurrently. Returned events are concatenated; order is unspecified —
+/// every caller keys the events by pubkey downstream, so ordering is irrelevant.
+async fn query_filter_batches(
+ state: &AppState,
+ semaphore: &tokio::sync::Semaphore,
+ filters: &[serde_json::Value],
+ error_label: &str,
+) -> Result, String> {
+ let pages = futures_util::future::try_join_all(filters.chunks(RELAY_FILTER_BATCH_SIZE).map(
+ |batch| async move {
+ let _permit = semaphore.acquire().await.map_err(|error| {
+ format!("{error_label}: directory concurrency semaphore closed: {error}")
+ })?;
+ query_relay(state, batch)
+ .await
+ .map_err(|error| format!("{error_label}: {error}"))
+ },
+ ))
+ .await?;
+ Ok(pages.into_iter().flatten().collect())
+}
fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec {
pubkeys
@@ -77,10 +112,30 @@ 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> {
+ 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();
let relay_pubkey = identity_archive::fetch_relay_self(state)
.await?
.ok_or_else(|| "relay agent membership authority is unavailable".to_string())?;
@@ -88,62 +143,82 @@ 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());
}
- let mut directory_events = Vec::new();
- let mut profile_events = Vec::new();
let directory_filters = exact_author_filters(&candidate_pubkeys, 10100);
let profile_filters = exact_author_filters(&candidate_pubkeys, 0);
- for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) {
- let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len());
- let (directory, profiles) = tokio::join!(
- query_relay(state, &directory_filters[filter_offset..filter_end]),
- query_relay(state, &profile_filters[filter_offset..filter_end]),
- );
- directory_events.extend(
- directory
- .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?,
- );
- profile_events.extend(
- profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?,
- );
- }
+ // One semaphore per rebuild caps `/query` requests across this rebuild's
+ // phases, so its runtime-directory and owner-profile phases below stay
+ // within the ceiling even though `try_join!` runs them concurrently.
+ let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY);
+ let (directory_events, profile_events) = tokio::try_join!(
+ query_filter_batches(
+ state,
+ &semaphore,
+ &directory_filters,
+ "relay agent runtime-directory query failed",
+ ),
+ query_filter_batches(
+ state,
+ &semaphore,
+ &profile_filters,
+ "relay agent owner-profile query failed",
+ ),
+ )?;
// Only the agent's signed NIP-OA profile can name the owner coordinate to
// query. Each exact `(owner, d=agent)` filter returns at most one current
// 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 managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners);
- let mut managed_agent_events = Vec::new();
- for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) {
- managed_agent_events.extend(
- query_relay(state, filters)
- .await
- .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?,
- );
+ let 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 managed_agent_events = query_filter_batches(
+ state,
+ &semaphore,
+ &managed_filters,
+ "relay agent managed-policy query failed",
+ )
+ .await?;
let mut agents = nostr_convert::relay_agents_from_directory_events(
&directory_events,
&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
@@ -159,10 +234,50 @@ 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::*;
+ #[test]
+ fn owner_only_directory_keeps_only_verified_same_owner_coordinates() {
+ let viewer = "a".repeat(64);
+ let other_owner = "b".repeat(64);
+ let same_owner_agent = "c".repeat(64);
+ let other_owner_agent = "d".repeat(64);
+ let mut owners = std::collections::HashMap::from([
+ (same_owner_agent.clone(), viewer.to_uppercase()),
+ (other_owner_agent, other_owner),
+ ]);
+
+ retain_verified_owner(&mut owners, &viewer);
+
+ assert_eq!(
+ owners,
+ std::collections::HashMap::from([(same_owner_agent, viewer.to_uppercase())])
+ );
+ }
+
#[test]
fn exact_author_queries_prevent_noisy_agent_crowd_out() {
let pubkeys = vec!["a".repeat(64), "b".repeat(64)];
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_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs
index c00cb5cd2d9..a9e3b677753 100644
--- a/desktop/src-tauri/src/commands/agent_models_tests.rs
+++ b/desktop/src-tauri/src/commands/agent_models_tests.rs
@@ -12,18 +12,35 @@ fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() {
&[],
RespondTo::OwnerOnly,
&[],
+ false,
));
assert!(managed_agent_access_policy_changed(
RespondTo::Allowlist,
&allowlist_a,
RespondTo::Allowlist,
&allowlist_b,
+ false,
));
assert!(!managed_agent_access_policy_changed(
RespondTo::OwnerOnly,
&allowlist_a,
RespondTo::OwnerOnly,
&allowlist_b,
+ false,
+ ));
+ assert!(!managed_agent_access_policy_changed(
+ RespondTo::Anyone,
+ &[],
+ RespondTo::OwnerOnly,
+ &[],
+ true,
+ ));
+ assert!(!managed_agent_access_policy_changed(
+ RespondTo::Allowlist,
+ &allowlist_a,
+ RespondTo::Allowlist,
+ &allowlist_b,
+ true,
));
}
diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs
index 922bee2e3e0..bb045b81a24 100644
--- a/desktop/src-tauri/src/commands/agent_models_update.rs
+++ b/desktop/src-tauri/src/commands/agent_models_update.rs
@@ -5,7 +5,15 @@ pub(crate) fn managed_agent_access_policy_changed(
current_allowlist: &[String],
prospective_mode: crate::managed_agents::RespondTo,
prospective_allowlist: &[String],
+ enforced_owner_only: bool,
) -> 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)?;
@@ -241,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..ed7a33d397d 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 +1168,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}"))?
@@ -1249,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/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/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.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..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,
}
}
@@ -265,7 +266,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/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/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/lib.rs b/desktop/src-tauri/src/lib.rs
index 862c8fa9a17..ec1d0498524 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::{
@@ -146,7 +145,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.
@@ -683,6 +681,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,
@@ -706,6 +705,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.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_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..9f234749bc9 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
@@ -73,6 +73,7 @@ fn minimal_record() -> ManagedAgentRecord {
definition_respond_to_allowlist: vec!["abc123def".to_string()],
definition_parallelism: Some(4),
relay_mesh: None,
+ effort_level: None,
}
}
@@ -233,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
assert_eq!((reader.info().width, reader.info().height), (3, 2));
}
-// ── PNG memory parity ─────────────────────────────────────────────────────
+#[test]
+fn png_snapshot_downscales_oversize_avatar_under_cap() {
+ // A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB)
+ // must be downscaled for the PNG body so the snapshot stays under the
+ // 10 MiB cap — while the manifest keeps the untouched source reference.
+ // An already-PNG oversize avatar exercises the `png_within_body_cap` guard
+ // that routes it through the downscaling transcode path.
+ let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| {
+ image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8])
+ }));
+ let mut source_bytes = Vec::new();
+ avatar
+ .write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png)
+ .unwrap();
+
+ let snapshot = build_snapshot(
+ &minimal_record(),
+ MemoryLevel::None,
+ vec![],
+ Some(&source_bytes),
+ );
+ let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap();
+
+ assert!(
+ png_bytes.len()
+ <= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4,
+ "downscaled snapshot ({} bytes) must be far under the 10 MiB cap",
+ png_bytes.len()
+ );
+
+ let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap();
+ let (width, height) = (reader.info().width, reader.info().height);
+ assert!(
+ width <= 512 && height <= 512,
+ "body dimensions {width}×{height} must fit the 512px cap"
+ );
+ // Aspect ratio preserved: the longest edge (height) is clamped to the cap.
+ assert_eq!(height, 512, "longest edge should hit the 512px cap");
+
+ // The manifest keeps the untouched full-resolution source reference — only
+ // the PNG body is downscaled. The oversize source bytes exceed the inline
+ // cap, so the manifest falls back to the record's `avatar_url`.
+ let manifest =
+ decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap();
+ assert_eq!(
+ manifest.profile.avatar_url.as_deref(),
+ Some("https://example.com/avatar.png"),
+ "manifest must preserve the untouched source avatar reference"
+ );
+ assert!(
+ manifest.profile.avatar_data_url.is_none(),
+ "oversize source bytes must not be inlined into the manifest"
+ );
+}
#[test]
fn png_round_trip_with_core_memory() {
diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs
new file mode 100644
index 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..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 },
@@ -175,6 +188,25 @@ pub struct RuntimeConfigSurface {
pub advanced: Vec,
pub extensions: Vec,
pub sources: ConfigSourceReport,
+ /// #3493: `true` when the panel is reading from a user-set `CLAUDE_CONFIG_DIR`
+ /// rather than the default `~/.claude/`. Used to show the Keychain caveat
+ /// note in the panel: a custom config dir means a fresh Keychain namespace
+ /// (hash-suffixed), so the agent will be logged out unless the user also
+ /// manages `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
+ #[serde(default)]
+ pub claude_config_dir_custom: bool,
+ /// B5: the real `configId` for the `thought_level` ACP config option,
+ /// as advertised by the adapter in `session/new`. Present only for claude
+ /// runtimes after the first session is created. The UI uses this to send
+ /// `set_config_option` without hardcoding the configId.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub effort_config_id: Option,
+ /// B5/I-7: the adapter-advertised option values for the `thought_level`
+ /// config option. Present when `effort_config_id` is Some. The UI renders
+ /// these instead of hardcoded low/medium/high so model-specific option sets
+ /// are reflected correctly.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub effort_options: Vec,
}
/// Raw config values extracted from a runtime's config file.
@@ -244,3 +276,105 @@ pub struct AcpModelEntry {
pub name: Option,
pub description: Option,
}
+
+#[cfg(test)]
+mod wire_format_tests {
+ use super::*;
+ use serde_json::json;
+
+ /// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts`
+ /// declares it. Whole-value comparison, not a key-set check: a key-set
+ /// assertion still passes if the variant *name* regresses, and the `type`
+ /// discriminant is what every `switch (writeVia.type)` reads. Compared as
+ /// `serde_json::Value` rather than as text, because JSON object order is
+ /// not semantic and the contract is the keys and values, not the encoder's
+ /// field order.
+ #[test]
+ fn wire_format_matches_typescript_contract() {
+ let cases = [
+ (
+ ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ },
+ json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}),
+ ),
+ (
+ ConfigWriteMechanism::AcpSetConfigOption {
+ config_id: "model".into(),
+ },
+ json!({"type": "acpSetConfigOption", "configId": "model"}),
+ ),
+ (
+ ConfigWriteMechanism::AcpSetSessionModel,
+ json!({"type": "acpSetSessionModel"}),
+ ),
+ (
+ ConfigWriteMechanism::GooseNativeConfigWrite {
+ config_key: "goose.model".into(),
+ },
+ json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}),
+ ),
+ (ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})),
+ ];
+ for (mechanism, expected) in cases {
+ assert_eq!(
+ serde_json::to_value(&mechanism).expect("serialize"),
+ expected
+ );
+ }
+ }
+
+ /// The renderer never sees a bare mechanism — it arrives nested inside
+ /// `NormalizedField`, which is where the mismatch used to hide: the
+ /// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all
+ /// renamed correctly, so only the variant's own field was snake_case.
+ #[test]
+ fn nested_field_is_camel_case_all_the_way_down() {
+ let field = NormalizedField {
+ value: Some("v".into()),
+ origin: ConfigOrigin::EnvVar,
+ write_via: ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ },
+ overridden_value: Some("o".into()),
+ overridden_origin: Some(ConfigOrigin::ConfigFile),
+ is_required: true,
+ };
+ assert_eq!(
+ serde_json::to_value(&field).expect("serialize"),
+ json!({
+ "value": "v",
+ "origin": "envVar",
+ "writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"},
+ "overriddenValue": "o",
+ "overriddenOrigin": "configFile",
+ "isRequired": true,
+ })
+ );
+ }
+
+ /// The contract is singular: the shape the renderer sends back round-trips,
+ /// and the old snake_case spelling is no longer accepted. Without the
+ /// second half, a future revert would still deserialize and the read path
+ /// would look healthy.
+ #[test]
+ fn camel_case_round_trips_and_snake_case_is_rejected() {
+ let parsed: ConfigWriteMechanism =
+ serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#)
+ .expect("the TypeScript shape must deserialize");
+ assert_eq!(
+ parsed,
+ ConfigWriteMechanism::RespawnWithEnvVar {
+ env_key: "GOOSE_MODE".into(),
+ }
+ );
+
+ assert!(
+ serde_json::from_str::(
+ r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"#
+ )
+ .is_err(),
+ "the pre-fix snake_case spelling must not be accepted"
+ );
+ }
+}
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index 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.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 67cdb5fbaf1..bc67a5b69eb 100644
--- a/desktop/src-tauri/src/managed_agents/nest/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs
@@ -422,415 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() {
);
}
-fn make_persona(id: &str, display_name: &str) -> AgentDefinition {
- AgentDefinition {
- id: id.to_string(),
- display_name: display_name.to_string(),
- avatar_url: None,
- system_prompt: String::new(),
- runtime: None,
- model: None,
- provider: None,
- name_pool: vec![],
- is_builtin: false,
- is_active: true,
- shared: false,
- source_team: None,
- source_team_persona_slug: None,
- catalog_source: None,
- env_vars: std::collections::BTreeMap::new(),
- respond_to: None,
- respond_to_allowlist: Vec::new(),
- parallelism: None,
- created_at: String::new(),
- updated_at: String::new(),
- }
-}
-
-fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
- ManagedAgentRecord {
- pubkey: String::new(),
- name: name.to_string(),
- persona_id: persona_id.map(|s| s.to_string()),
- private_key_nsec: String::new(),
- auth_tag: None,
- relay_url: String::new(),
- avatar_url: None,
- acp_command: String::new(),
- agent_command: String::new(),
- agent_command_override: None,
- agent_args: vec![],
- mcp_command: String::new(),
- turn_timeout_seconds: 0,
- idle_timeout_seconds: None,
- max_turn_duration_seconds: None,
- parallelism: 1,
- system_prompt: None,
- model: None,
- provider: None,
- persona_source_version: None,
- start_on_app_launch: false,
- auto_restart_on_config_change: true,
- runtime_pid: None,
- backend: BackendKind::default(),
- backend_agent_id: None,
- provider_policy_pending: false,
- provider_binary_path: None,
- team_id: None,
- persona_team_dir: None,
- persona_name_in_team: None,
- created_at: String::new(),
- updated_at: String::new(),
- last_started_at: None,
- last_stopped_at: None,
- last_exit_code: None,
- last_error: None,
- last_error_code: None,
- respond_to: RespondTo::default(),
- respond_to_allowlist: vec![],
- env_vars: std::collections::BTreeMap::new(),
- display_name: None,
- slug: None,
- runtime: None,
- name_pool: Vec::new(),
- is_builtin: false,
- is_active: true,
- shared: false,
- source_team: None,
- source_team_persona_slug: None,
- catalog_source: None,
- definition_respond_to: None,
- definition_respond_to_allowlist: Vec::new(),
- definition_parallelism: None,
- relay_mesh: None,
- }
-}
-
-#[test]
-fn test_render_dynamic_section_with_agents() {
- let personas = vec![make_persona("p1", "Builder")];
- let agents = vec![make_agent("Kit", Some("p1"))];
- let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
- assert!(output.contains("| Kit | Builder | @Kit |"));
- assert!(output.contains("| Name | Persona | How to address |"));
- assert!(output.contains("## Workspace"));
-}
-
-#[test]
-fn test_render_dynamic_section_empty() {
- let output = render_dynamic_section(&[], &[], "ws://example.com:3000");
- assert!(output.contains("No agents deployed yet"));
-}
-
-#[test]
-fn test_render_dynamic_section_agent_no_persona() {
- let personas = vec![make_persona("p1", "Builder")];
- let agents = vec![make_agent("Scout", Some("nonexistent"))];
- let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000");
- assert!(output.contains("| Scout | — | @Scout |"));
-}
-
-#[test]
-fn test_upsert_managed_section_with_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\nsome content\n\n\nold section\n\n\nafter\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "new section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
- assert!(result.contains(""));
- assert!(result.contains("new section"));
- assert!(!result.contains("old section"));
- assert!(result.contains("# Header"));
- assert!(result.contains("some content"));
- assert!(result.contains("after"));
-}
-
-#[test]
-fn test_upsert_managed_section_without_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(&file, "# Header\n\nexisting content\n").unwrap();
-
- upsert_managed_section(&file, "injected section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
- assert!(result.contains("# Header"));
- assert!(result.contains("existing content"));
- assert!(result.contains(""));
- assert!(result.contains("injected section"));
- let begin_pos = result.find("\nsome middle content\n\nold section\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "new section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(result.contains("# Header"), "original header must survive");
- assert!(
- result.contains("new section"),
- "new content must be present"
- );
- assert!(
- result.contains("some middle content"),
- "content between markers must survive"
- );
-
- // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended).
- assert_eq!(
- result.matches(BEGIN_MARKER).count(),
- 1,
- "exactly one BEGIN marker after orphan cleanup"
- );
-
- // The single BEGIN marker must have a matching END marker after it.
- let begin_pos = result
- .find(BEGIN_MARKER)
- .expect("BEGIN marker must be present");
- let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p);
- assert!(
- end_pos.is_some(),
- "an END marker must appear after the appended BEGIN marker"
- );
-}
-
-#[test]
-fn test_upsert_begin_only_no_end() {
- // A file with BEGIN but no END has an orphan marker.
- // find_managed_markers returns None (no END found after BEGIN),
- // so strip_orphan_begin_marker removes the BEGIN line.
- // Content that followed the orphan BEGIN is preserved (only the marker line is stripped,
- // not the body that came after it).
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\nsome content\n\n\norphaned section without end marker\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "fresh section").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(result.contains("# Header"), "original header must survive");
- assert!(
- result.contains("some content"),
- "original body must survive"
- );
- assert!(
- result.contains("fresh section"),
- "new content must be present"
- );
-
- let begin_pos = result
- .find(BEGIN_MARKER)
- .expect("BEGIN marker must be present");
- let end_pos = result.find(END_MARKER).expect("END marker must be present");
- assert!(
- begin_pos < end_pos,
- "the appended BEGIN marker must precede the appended END marker"
- );
-
- // Exactly one BEGIN marker after orphan cleanup.
- assert_eq!(
- result.matches(BEGIN_MARKER).count(),
- 1,
- "exactly one BEGIN marker after orphan cleanup"
- );
-}
-
-#[test]
-fn test_upsert_duplicate_markers() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- fs::write(
- &file,
- "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "replaced").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(
- result.contains("replaced"),
- "replacement content must be present"
- );
- assert!(
- !result.contains("first block"),
- "first block must be replaced"
- );
- assert!(
- result.contains("second block"),
- "second pair content must survive"
- );
- assert!(
- result.contains("between blocks"),
- "text between pairs must survive"
- );
-}
-
-#[test]
-fn test_upsert_marker_in_code_block() {
- let tmp = tempfile::tempdir().unwrap();
- let file = tmp.path().join("AGENTS.md");
- // Indented by 4 spaces — not at column 0, so should NOT match as a real marker.
- fs::write(
- &file,
- "# Header\n\n \n\nReal content here\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "appended content").unwrap();
-
- let result = fs::read_to_string(&file).unwrap();
-
- assert!(
- result.contains(" "),
- "indented marker inside code block must be preserved verbatim"
- );
- assert!(
- result.contains("appended content"),
- "new content must be appended"
- );
- assert!(
- result.contains("Real content here"),
- "existing body must survive"
- );
-
- // The real markers appended at the end must be at line-start (column 0).
- let begin_pos = result
- .find("\nexisting section\n\n",
- )
- .unwrap();
-
- upsert_managed_section(&file, "same content").unwrap();
- let after_first = fs::read_to_string(&file).unwrap();
-
- upsert_managed_section(&file, "same content").unwrap();
- let after_second = fs::read_to_string(&file).unwrap();
-
- assert_eq!(
- after_first, after_second,
- "upsert must be idempotent: second call must not alter the file"
- );
-}
-
#[test]
fn refresh_agents_md_writes_version_file() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs
index 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 b1c342e9955..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};
@@ -133,6 +134,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 +197,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,16 +242,16 @@ 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(),
);
(runtime, current)
});
@@ -776,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);
@@ -811,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
@@ -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/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/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..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,
@@ -72,6 +73,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.
@@ -123,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 {
@@ -136,7 +165,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(),
@@ -145,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),
@@ -155,16 +197,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
@@ -174,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),
}
}
@@ -213,6 +258,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 +308,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/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 20e02871eba..b007e0b2ffa 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,13 @@ 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)
+}
+
+/// `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 {
@@ -71,6 +96,7 @@ fn record() -> ManagedAgentRecord {
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
+ effort_level: None,
}
}
@@ -225,6 +251,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();
@@ -828,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-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-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs
index 56dfc5a2323..8155f71679d 100644
--- a/desktop/src-tauri/src/relay.rs
+++ b/desktop/src-tauri/src/relay.rs
@@ -34,7 +34,7 @@ pub fn relay_ws_url() -> String {
/// Read the workspace relay URL override, if set. Returns `None` when no
/// override is active or when the mutex is poisoned (best-effort).
-fn workspace_relay_override(state: &AppState) -> Option {
+pub(crate) fn workspace_relay_override(state: &AppState) -> Option {
state
.relay_url_override
.lock()
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index ede267ee445..9233ae622c4 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": "BitcoinMarkets",
- "version": "0.5.15",
+ "version": "0.5.17",
"identifier": "app.bitcoinmarkets.desktop",
"build": {
"beforeDevCommand": {
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 33bcfa6eb69..509c00fbfd3 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -896,6 +896,7 @@ export function AppShell() {
/>
) : null}
;
@@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = {
export function AppShellChannelSurface({
children,
+ hasCommunityRail,
isHuddleRoom,
isHuddleRoomStarting,
mainInsetRef,
terminal,
}: AppShellChannelSurfaceProps) {
+ const { isMobile, openMobile, state: sidebarState } = useSidebar();
+ const hasCollapsedSidebarGutter =
+ !isHuddleRoom &&
+ !hasCommunityRail &&
+ (isMobile ? !openMobile : sidebarState === "collapsed");
+
return (
+ {hasCollapsedSidebarGutter ? (
+
+ ) : null}
{isHuddleRoom && !isHuddleRoomStarting ? : null}
{isHuddleRoomStarting ? : children}
diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts
index 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/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index 53a39824c4e..88f2a3c9821 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -201,6 +201,52 @@ 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
+ 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
@@ -230,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/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/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/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/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts
index 7ae4d0bfc81..68fa290ad25 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}`;
@@ -257,8 +272,10 @@ function appendAgentEvents(
}
if (added.length === 0) return null;
- const sortedAdded = added.sort(compareObserverEvents);
- const sorted = [...current, ...sortedAdded].sort(compareObserverEvents);
+ const sortedAdded = [...added].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();
@@ -454,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.
@@ -486,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) => {
@@ -498,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 });
}
}
@@ -625,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;
}
@@ -633,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/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/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/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/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/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/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/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/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({
)}
-
+
({ 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/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index aeb7198b4c5..f812eb91156 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -510,7 +510,7 @@ export function useRichTextEditor({
attributes: {
autocapitalize: "none",
autocorrect: "off",
- class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-5 text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
+ class: `${MESSAGE_MARKDOWN_CLASS} min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-message font-normal tracking-normal text-foreground shadow-none focus-visible:ring-0 caret-foreground outline-hidden max-w-none`,
"data-testid": "message-input",
spellcheck: "true",
},
diff --git a/desktop/src/features/messages/ui/DiffViewer.css b/desktop/src/features/messages/ui/DiffViewer.css
index dcc97fb7885..6b4c4168aa2 100644
--- a/desktop/src/features/messages/ui/DiffViewer.css
+++ b/desktop/src/features/messages/ui/DiffViewer.css
@@ -52,7 +52,7 @@
}
.buzz-diff-theme .diff {
- font-size: 0.75rem;
+ font-size: calc(var(--buzz-type-rem) * 0.75);
}
.buzz-diff-theme .diff td {
@@ -68,7 +68,7 @@
padding: 0.125rem 0.5rem;
border-right: 1px solid hsl(var(--border) / 0.65);
color: hsl(var(--muted-foreground));
- font-size: 0.6875rem;
+ font-size: calc(var(--buzz-type-rem) * 0.6875);
}
.buzz-diff-theme .buzz-diff-code {
@@ -86,7 +86,7 @@
padding: 0.2rem 0.75rem;
background: hsl(var(--muted) / 0.35);
color: hsl(var(--muted-foreground));
- font-size: 0.6875rem;
+ font-size: calc(var(--buzz-type-rem) * 0.6875);
}
.buzz-diff-theme .diff-gutter-omit::before {
diff --git a/desktop/src/features/messages/ui/MessageHeader.tsx b/desktop/src/features/messages/ui/MessageHeader.tsx
index f2f25224d75..24bef0f542b 100644
--- a/desktop/src/features/messages/ui/MessageHeader.tsx
+++ b/desktop/src/features/messages/ui/MessageHeader.tsx
@@ -14,9 +14,10 @@ export function MessageHeaderRow({
return (
{children}
@@ -93,7 +94,7 @@ export function MessageAuthorText({
return (
{
if (reaction.users.length === 0) return;
clearTimers();
- openTimeout.current = setTimeout(() => setOpen(true), 200);
+ openTimeout.current = setTimeout(
+ () => setOpen(true),
+ DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS,
+ );
}, [reaction.users.length, clearTimers]);
const scheduleClose = React.useCallback(() => {
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index de496836c19..fd5be7d9a86 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -295,7 +295,7 @@ export const MessageRow = React.memo(
message.body,
message.tags,
);
- const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
+ const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
@@ -411,7 +411,7 @@ export const MessageRow = React.memo(
{authorNode}
@@ -868,7 +868,7 @@ export const MessageRow = React.memo(
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
- "py-1",
+ "py-conversation-row",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
@@ -888,17 +888,21 @@ export const MessageRow = React.memo(
{isThreadReplyLayout ? (
<>
{avatarGutterNode}
-
+
{headerNode}
-
{messageBodyNode}
+
+ {messageBodyNode}
+
>
) : (
<>
{avatarGutterNode}
-
+
{headerNode}
-
{messageBodyNode}
+
+ {messageBodyNode}
+
>
)}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 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 ? (
{
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[]) {
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/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/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/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 (
-
+
@@ -123,6 +126,8 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
+ initializeConversationDensityPreference();
+ initializeFontSizePreference();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts
index 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/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/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/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/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 = {
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/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/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/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/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}
+
);
}
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/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/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/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx
index 41db47b15c6..5dbda329876 100644
--- a/desktop/src/shared/ui/sidebar.tsx
+++ b/desktop/src/shared/ui/sidebar.tsx
@@ -24,7 +24,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
-
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width";
@@ -37,7 +36,8 @@ const SIDEBAR_WIDTH_MAX = 420;
const SIDEBAR_WIDTH_MOBILE = "288px";
const SIDEBAR_WIDTH_ICON = "48px";
const SIDEBAR_KEYBOARD_SHORTCUT = "s";
-
+// Increases button hit areas on mobile without changing their layout.
+const MOBILE_ACTION_HIT_AREA = "after:absolute after:-inset-2 after:md:hidden";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
@@ -52,7 +52,6 @@ type SidebarContextProps = {
setSidebarWidth: (width: number | ((width: number) => number)) => void;
toggleSidebar: () => void;
};
-
const SidebarContext = React.createContext(null);
function useSidebar() {
@@ -133,7 +132,6 @@ function readSidebarWidth() {
? clampSidebarWidth(storedWidth)
: SIDEBAR_WIDTH_DEFAULT;
}
-
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
@@ -220,10 +218,8 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
+ // Expose semantic state so Tailwind descendants can style both modes.
const state = open ? "expanded" : "collapsed";
-
const contextValue = React.useMemo(
() => ({
state,
@@ -255,7 +251,7 @@ const SidebarProvider = React.forwardRef<
return (
-
+
@@ -381,7 +378,12 @@ const Sidebar = React.forwardRef<
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
- {children}
+
+ {children}
+
@@ -707,8 +709,7 @@ const SidebarGroupAction = React.forwardRef<
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 after:md:hidden",
+ MOBILE_ACTION_HIT_AREA,
"group-data-[collapsible=icon]:hidden",
className,
)}
@@ -853,8 +854,7 @@ const SidebarMenuAction = React.forwardRef<
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 after:md:hidden",
+ MOBILE_ACTION_HIT_AREA,
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
diff --git a/desktop/src/shared/ui/tooltip.tsx b/desktop/src/shared/ui/tooltip.tsx
index afa58c753e4..6af9e481887 100644
--- a/desktop/src/shared/ui/tooltip.tsx
+++ b/desktop/src/shared/ui/tooltip.tsx
@@ -3,7 +3,23 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/cn";
-const TooltipProvider = TooltipPrimitive.Provider;
+// Hover-only disclosure should require deliberate pointer dwell. Disabling Radix's
+// skip-delay grace prevents tooltips from cascading open while the pointer moves
+// across adjacent controls. Callers may override both values for a proven case.
+const DEFAULT_TOOLTIP_DELAY_MS = 500;
+const DEFAULT_TOOLTIP_SKIP_DELAY_MS = 0;
+
+const TooltipProvider = ({
+ delayDuration = DEFAULT_TOOLTIP_DELAY_MS,
+ skipDelayDuration = DEFAULT_TOOLTIP_SKIP_DELAY_MS,
+ ...props
+}: React.ComponentProps
) => (
+
+);
const Tooltip = TooltipPrimitive.Root;
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 4215d183ac2..1f7b1477494 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[];
@@ -297,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;
@@ -408,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
@@ -851,6 +858,7 @@ type RawSendChannelMessageResponse = {
type RawRelayAgent = {
pubkey: string;
+ owner_pubkey?: string | null;
name: string;
agent_type: string;
channels: string[];
@@ -1809,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",
@@ -2324,6 +2338,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),
@@ -3057,6 +3072,7 @@ const mockAuthResponses: Array<{ success: boolean; message: string }> = [];
const mockChannelHistoryCloses: string[] = [];
let mockWebsocketUnavailable = false;
const relayWebsocketConnectAttemptStarts: number[] = [];
+let mockAuthSigningAttempts = 0;
let mockWebsocketSendMutexWedged = false;
let mockClosedChannelLiveSubscription = false;
const realSockets = new Map();
@@ -9752,9 +9768,14 @@ async function connectMockSocket(args: { onMessage: unknown }) {
subscriptions: new Map(),
});
- window.setTimeout(() => {
+ if (getConfig()?.mock?.websocketAuthBeforeConnectResolves) {
sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
- }, 0);
+ await new Promise((resolve) => window.setTimeout(resolve, 50));
+ } else {
+ window.setTimeout(() => {
+ sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
+ }, 0);
+ }
return wsId;
}
@@ -10243,6 +10264,7 @@ export function maybeInstallE2eTauriMocks() {
mockAuthResponses.length = 0;
mockChannelHistoryCloses.length = 0;
relayWebsocketConnectAttemptStarts.length = 0;
+ mockAuthSigningAttempts = 0;
deferredSendMessageLiveEchoes.length = 0;
deferredLinkPreviewMetadataQueue = [];
deferredLinkPreviewUploadQueue = [];
@@ -12210,6 +12232,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":
@@ -13105,6 +13148,13 @@ export function maybeInstallE2eTauriMocks() {
case "nip44_decrypt_from_self":
return (payload as { ciphertext: string }).ciphertext;
case "create_auth_event":
+ mockAuthSigningAttempts++;
+ if (
+ getConfig()?.mock?.stallFirstAuthSigning &&
+ mockAuthSigningAttempts === 1
+ ) {
+ return new Promise(() => {});
+ }
if (identity) {
return JSON.stringify(
await signWithIdentity(identity, {
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 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 e191d293045..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);
@@ -2287,7 +2289,9 @@ test("people sharing blocks a timeout before encoding or upload", async ({
});
await page.getByTestId("persona-share-send").click();
- await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible();
+ await expect(
+ page.getByText("You are currently timed out and cannot send messages."),
+ ).toBeVisible();
const commands = await readAgentShareCommands(page);
expect(
@@ -2332,7 +2336,11 @@ test("people sharing rechecks destination eligibility after encoding", async ({
return testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__?.();
});
- await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible({
+ await expect(
+ page.getByText(
+ "The selected destination is no longer available. Please pick another.",
+ ),
+ ).toBeVisible({
timeout: 5_000,
});
const commands = await readAgentShareCommands(page);
diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts
index 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/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index 3f6d38602ab..57b35db386e 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
const RELAY_URL = "ws://localhost:3000";
+const THEME_STORAGE_KEY = "buzz-theme";
const OWNER_PUBKEY = "deadbeef".repeat(8);
function snapshotKey(relayUrl: string) {
@@ -23,6 +24,25 @@ const COMMUNITY_B = {
addedAt: "2026-01-02T00:00:00.000Z",
};
+async function expectContentSurfaceHorizontalGutters(
+ page: import("@playwright/test").Page,
+ expectedLeftGutter = 1,
+) {
+ const [mainInsetBox, contentBox] = await Promise.all([
+ page.locator("[data-buzz-glass-inset]").boundingBox(),
+ page.locator("[data-buzz-content-surface]").first().boundingBox(),
+ ]);
+ expect(mainInsetBox).not.toBeNull();
+ expect(contentBox).not.toBeNull();
+ const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0);
+ const rightGutter =
+ (mainInsetBox?.x ?? 0) +
+ (mainInsetBox?.width ?? 0) -
+ ((contentBox?.x ?? 0) + (contentBox?.width ?? 0));
+ expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5);
+ expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5);
+}
+
async function seedCommunities(
page: import("@playwright/test").Page,
communities: Array>,
@@ -64,7 +84,7 @@ test.describe("community rail", () => {
"overflow",
"visible",
);
- await expect(rail).toHaveCSS("z-index", "0");
+ await expect(rail).toHaveCSS("z-index", "20");
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
@@ -137,6 +157,7 @@ test.describe("community rail", () => {
// The add-community affordance lives at the bottom of the rail.
await expect(page.getByTestId("community-rail-add")).toBeVisible();
+ await expectContentSurfaceHorizontalGutters(page);
});
test("restores pointer events after dismissing community settings", async ({
@@ -275,10 +296,87 @@ test.describe("community rail", () => {
expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0);
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
- await page.getByTestId("community-switcher").click();
-
const menu = page.getByRole("menu", { name: "Community actions" });
+ await communityTrigger.evaluate((trigger) => {
+ trigger.addEventListener(
+ "mouseenter",
+ () => {
+ trigger.dataset.hoverStartedAt = String(performance.now());
+ },
+ { once: true },
+ );
+ trigger.addEventListener("mouseleave", () => {
+ trigger.dataset.leftAt = String(performance.now());
+ });
+ const observer = new MutationObserver((records) => {
+ if (
+ trigger.getAttribute("aria-expanded") === "true" &&
+ !trigger.dataset.expandedAt
+ ) {
+ trigger.dataset.expandedAt = String(performance.now());
+ }
+ if (
+ records.some(
+ (record) =>
+ record.attributeName === "aria-expanded" &&
+ record.oldValue === "true",
+ )
+ ) {
+ trigger.dataset.closedAfterOpening = "true";
+ }
+ });
+ observer.observe(trigger, {
+ attributeFilter: ["aria-expanded"],
+ attributeOldValue: true,
+ attributes: true,
+ });
+ });
+ await communityTrigger.hover();
+ await expect(menu).toBeVisible({ timeout: 700 });
+ const openDelayMs = await communityTrigger.evaluate((trigger) => {
+ const hoverStartedAt = Number(trigger.dataset.hoverStartedAt);
+ const expandedAt = Number(trigger.dataset.expandedAt);
+ if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) {
+ throw new Error("Community actions open timing was not recorded");
+ }
+ return expandedAt - hoverStartedAt;
+ });
+ expect(openDelayMs).toBeGreaterThanOrEqual(40);
+ expect(openDelayMs).toBeLessThan(300);
+
+ const openTriggerBox = await communityTrigger.boundingBox();
+ const menuBox = await menu.boundingBox();
+ expect(openTriggerBox).not.toBeNull();
+ expect(menuBox).not.toBeNull();
+ if (!openTriggerBox || !menuBox) {
+ throw new Error("Community actions geometry unavailable");
+ }
+ const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1;
+ const triggerExitY = Math.min(
+ openTriggerBox.y + openTriggerBox.height - 4,
+ menuBox.y + menuBox.height - 4,
+ );
+ await page.mouse.move(triggerExitX, triggerExitY);
+ await page.mouse.move(menuBox.x + 8, menuBox.y - 8);
+ await page.waitForTimeout(80);
+ await page.mouse.move(menuBox.x + 8, menuBox.y + 8);
+ const bridgeDurationMs = await communityTrigger.evaluate((trigger) => {
+ const leftAt = Number(trigger.dataset.leftAt);
+ if (!Number.isFinite(leftAt)) {
+ throw new Error(
+ "Community actions trigger exit timing was not recorded",
+ );
+ }
+ return performance.now() - leftAt;
+ });
+ expect(bridgeDurationMs).toBeGreaterThanOrEqual(60);
+ expect(bridgeDurationMs).toBeLessThan(140);
+ await page.waitForTimeout(180);
await expect(menu).toBeVisible();
+ await expect(communityTrigger).not.toHaveAttribute(
+ "data-closed-after-opening",
+ "true",
+ );
await expect(
menu.getByRole("menuitem", { name: "Copy community URL" }),
).toBeVisible();
@@ -1133,7 +1231,36 @@ test.describe("community rail", () => {
).toBeVisible();
});
+ test("keeps the gutter when the mobile sidebar closes without a rail", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 740, height: 516 });
+ await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
+ await page.goto("/");
+
+ await page
+ .getByRole("button", { name: "Toggle Sidebar", exact: true })
+ .click();
+ await expect(
+ page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await expect(
+ page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
+ ).toBeHidden();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "width",
+ "8px",
+ );
+ await expectContentSurfaceHorizontalGutters(page, 9);
+ });
+
test("hides the rail with a single community", async ({ page }) => {
+ await page.addInitScript((themeStorageKey) => {
+ window.localStorage.setItem(themeStorageKey, "buzz-dark");
+ }, THEME_STORAGE_KEY);
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
await page.goto("/");
@@ -1142,6 +1269,25 @@ test.describe("community rail", () => {
// adds nothing).
await expect(page.getByTestId("app-sidebar")).toBeVisible();
await expect(page.getByTestId("community-rail")).toHaveCount(0);
+
+ await page
+ .getByRole("button", { name: "Toggle Sidebar", exact: true })
+ .click();
+ await expect(
+ page.locator('[data-side="left"][data-state="collapsed"]'),
+ ).toBeVisible();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "width",
+ "8px",
+ );
+ const sidebarBackground = await page
+ .locator("[data-buzz-glass-inset]")
+ .evaluate((element) => getComputedStyle(element).backgroundColor);
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS(
+ "background-color",
+ sidebarBackground,
+ );
+ await expectContentSurfaceHorizontalGutters(page, 9);
});
test("keeps the rail visible when the sidebar is collapsed", async ({
@@ -1174,6 +1320,10 @@ test.describe("community rail", () => {
page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`),
).toBeVisible();
await expect(page.getByTestId("community-rail-add")).toBeVisible();
+ await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount(
+ 0,
+ );
+ await expectContentSurfaceHorizontalGutters(page);
});
test("clears the macOS traffic lights", async ({ page }) => {
diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts
index 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)");
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/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 4efd6dd7a3f..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",
@@ -1454,6 +1488,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,
}) => {
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/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/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" });
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,
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
diff --git a/lefthook.yml b/lefthook.yml
index 5b992f19afe..b2b9f18894a 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.
@@ -25,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.
@@ -51,8 +56,12 @@ 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"]
+ 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"]
@@ -70,8 +79,11 @@ pre-push:
# Keep local lint parity with Desktop Core CI for every path that can
# affect the Tauri crate or its path dependencies. Run clippy and tests
# serially so parallel pre-push hooks do not contend for Cargo's lock.
- glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"]
+ glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "Justfile"]
run: just desktop-tauri-clippy && just desktop-tauri-test
+ mobile-check:
+ glob: ["mobile/**"]
+ run: just mobile-check
mobile-test:
glob: ["mobile/**"]
run: just mobile-test
diff --git a/mobile/README.md b/mobile/README.md
index 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 630c2a5d704..c0949d2e7cb 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,
)
}
@@ -112,10 +133,13 @@ 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) {
+ // FORK-LOCAL PATCH (adrienlacombe/buzz): brand rename.
resValue("string", "app_name", "BitcoinMarkets ($worktreeLabel)")
}
}
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 7ccf13bae4d..f6f66ee2dc2 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -14,6 +14,12 @@
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 */; };
+ 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 */; };
@@ -59,6 +65,12 @@
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 = ""; };
+ 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; };
+ 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = ""; };
+ 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = ""; };
+ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -182,6 +194,12 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
+ 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
+ 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
+ 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */,
+ 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */,
+ 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */,
+ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -417,6 +435,12 @@
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 */,
+ 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */,
+ 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */,
+ 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */,
+ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 6ab55c359c7..e4ee6dbd916 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -10,6 +10,8 @@ import UserNotifications
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
+ private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
+ private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -88,6 +90,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"
)
@@ -95,6 +115,34 @@ import UserNotifications
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
+
+ let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzNativeEmojiPicker"
+ )
+ nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator(
+ messenger: messenger,
+ parentViewController: nativeEmojiPickerRegistrar?.viewController
+ )
+ if #available(iOS 16.0, *),
+ let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
+ forPlugin: "BuzzNativeMessageActionSurface"
+ ) {
+ nativeMessageActionsRegistrar.register(
+ NativeMessageActionSurfaceFactory(messenger: messenger),
+ withId: "buzz/native_message_action_surface"
+ )
+ nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel(
+ name: "buzz/native_message_action_surface",
+ binaryMessenger: messenger
+ )
+ nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in
+ guard call.method == "isSupported" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ result(true)
+ }
+ }
}
private static func handleQrScannerMethodCall(
diff --git a/mobile/ios/Runner/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/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift
new file mode 100644
index 00000000000..3109923daba
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPicker.swift
@@ -0,0 +1,193 @@
+import Flutter
+import SwiftUI
+import UIKit
+
+private struct NativeEmojiMediaHeaderError: Error {}
+
+final class NativeEmojiPickerCoordinator: NSObject,
+ UIAdaptivePresentationControllerDelegate
+{
+ private let channel: FlutterMethodChannel
+ private static weak var activeCoordinator: NativeEmojiPickerCoordinator?
+ private weak var parentViewController: UIViewController?
+ private weak var presentedController: UIViewController?
+ private var didNotifyDismissal = false
+ private var isDismissing = false
+
+ init(
+ messenger: FlutterBinaryMessenger,
+ parentViewController: UIViewController?
+ ) {
+ channel = FlutterMethodChannel(
+ name: "buzz/native_emoji_picker",
+ binaryMessenger: messenger
+ )
+ self.parentViewController = parentViewController
+ super.init()
+ Self.activeCoordinator = self
+ channel.setMethodCallHandler { [weak self] call, result in
+ self?.handle(call, result: result)
+ }
+ }
+
+ static func mediaHeaders(for url: URL) async throws -> [String: String] {
+ guard let channel = activeCoordinator?.channel else { return [:] }
+ return try await withCheckedThrowingContinuation { continuation in
+ channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in
+ if result is FlutterError {
+ continuation.resume(throwing: NativeEmojiMediaHeaderError())
+ return
+ }
+ continuation.resume(returning: result as? [String: String] ?? [:])
+ }
+ }
+ }
+
+ private func handle(
+ _ call: FlutterMethodCall,
+ result: @escaping FlutterResult
+ ) {
+ guard call.method == "present" else {
+ result(FlutterMethodNotImplemented)
+ return
+ }
+ guard let arguments = call.arguments as? [String: Any] else {
+ result(
+ FlutterError(
+ code: "invalid_arguments",
+ message: "Expected emoji-picker configuration.",
+ details: nil
+ )
+ )
+ return
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ result(self?.present(arguments: arguments) ?? false)
+ }
+ }
+
+ @MainActor
+ private func present(arguments: [String: Any]) -> Bool {
+ // A sheet is already owned by an earlier caller; report busy rather than a
+ // successful presentation so the caller does not treat this as its own.
+ guard presentedController == nil else { return false }
+ guard
+ let data = NativeEmojiPickerDataLoader.load(arguments: arguments),
+ let presenter = topViewController(
+ from: parentViewController ?? activeWindowRootViewController()
+ )
+ else {
+ return false
+ }
+
+ didNotifyDismissal = false
+ isDismissing = false
+ let appearance = NativeEmojiPickerAppearance(arguments: arguments)
+ let content = NativeEmojiPickerView(
+ data: data,
+ appearance: appearance,
+ initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0,
+ onSelect: { [weak self] emoji in self?.select(emoji) },
+ onSkinToneChanged: { [weak self] value in
+ self?.channel.invokeMethod("skinToneChanged", arguments: value)
+ },
+ onClose: { [weak self] in self?.dismiss() }
+ )
+ let controller = UIHostingController(rootView: content)
+ controller.view.backgroundColor = appearance.surface
+ controller.modalPresentationStyle = .pageSheet
+ controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light
+
+ if let sheet = controller.sheetPresentationController {
+ let compactID = UISheetPresentationController.Detent.Identifier(
+ "buzz.emoji.compact"
+ )
+ let mediumID = UISheetPresentationController.Detent.Identifier(
+ "buzz.emoji.medium"
+ )
+ sheet.detents = [
+ .custom(identifier: compactID) { context in
+ context.maximumDetentValue * 0.34
+ },
+ .custom(identifier: mediumID) { context in
+ context.maximumDetentValue * 0.67
+ },
+ .large(),
+ ]
+ sheet.selectedDetentIdentifier = mediumID
+ sheet.prefersGrabberVisible = true
+ sheet.prefersScrollingExpandsWhenScrolledToEdge = false
+ sheet.prefersEdgeAttachedInCompactHeight = false
+ sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
+ }
+
+ presentedController = controller
+ presenter.present(controller, animated: true) { [weak self, weak controller] in
+ controller?.presentationController?.delegate = self
+ }
+ return true
+ }
+
+ @MainActor
+ private func select(_ emoji: String) {
+ // A single presentation returns at most one selection. The sheet stays
+ // live through its dismissal animation, so ignore extra taps that arrive
+ // before dismissal completes to avoid emitting duplicate selections.
+ guard !isDismissing else { return }
+ channel.invokeMethod("selected", arguments: emoji)
+ dismiss()
+ }
+
+ @MainActor
+ private func dismiss() {
+ isDismissing = true
+ guard let controller = presentedController else {
+ notifyDismissalIfNeeded()
+ return
+ }
+ controller.dismiss(animated: true) { [weak self] in
+ self?.notifyDismissalIfNeeded()
+ }
+ }
+
+ func presentationControllerDidDismiss(
+ _ presentationController: UIPresentationController
+ ) {
+ notifyDismissalIfNeeded()
+ }
+
+ @MainActor
+ private func notifyDismissalIfNeeded() {
+ guard !didNotifyDismissal else { return }
+ didNotifyDismissal = true
+ presentedController = nil
+ channel.invokeMethod("dismissed", arguments: nil)
+ }
+
+ @MainActor
+ private func activeWindowRootViewController() -> UIViewController? {
+ UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .filter { $0.activationState == .foregroundActive }
+ .flatMap(\.windows)
+ .first(where: \.isKeyWindow)?
+ .rootViewController
+ }
+
+ @MainActor
+ private func topViewController(
+ from viewController: UIViewController?
+ ) -> UIViewController? {
+ if let presented = viewController?.presentedViewController {
+ return topViewController(from: presented)
+ }
+ if let navigation = viewController as? UINavigationController {
+ return topViewController(from: navigation.visibleViewController)
+ }
+ if let tab = viewController as? UITabBarController {
+ return topViewController(from: tab.selectedViewController)
+ }
+ return viewController
+ }
+}
diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift
new file mode 100644
index 00000000000..75bf46446cd
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift
@@ -0,0 +1,437 @@
+import Flutter
+import SwiftUI
+import UIKit
+
+struct NativeEmojiPickerAppearance {
+ let surface: UIColor
+ let control: UIColor
+ let text: UIColor
+ let secondaryText: UIColor
+ let accent: UIColor
+ let divider: UIColor
+ let isDark: Bool
+
+ init(arguments: [String: Any]) {
+ surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground)
+ control = Self.color(
+ arguments["controlColor"],
+ fallback: .secondarySystemBackground
+ )
+ text = Self.color(arguments["textColor"], fallback: .label)
+ secondaryText = Self.color(
+ arguments["secondaryTextColor"],
+ fallback: .secondaryLabel
+ )
+ accent = Self.color(arguments["accentColor"], fallback: .systemBlue)
+ divider = Self.color(arguments["dividerColor"], fallback: .separator)
+ isDark = arguments["isDark"] as? Bool ?? false
+ }
+
+ private static func color(_ raw: Any?, fallback: UIColor) -> UIColor {
+ guard let value = (raw as? NSNumber)?.uint32Value else { return fallback }
+ let alpha = CGFloat((value >> 24) & 0xFF) / 255
+ let red = CGFloat((value >> 16) & 0xFF) / 255
+ let green = CGFloat((value >> 8) & 0xFF) / 255
+ let blue = CGFloat(value & 0xFF) / 255
+ return UIColor(red: red, green: green, blue: blue, alpha: alpha)
+ }
+}
+
+struct NativeEmojiItem: Identifiable, Hashable {
+ let id: String
+ let shortcode: String
+ let value: String
+ let name: String
+ let keywords: [String]
+ let glyph: String?
+ let skinVariants: [String]
+ let imageURL: URL?
+}
+
+struct NativeEmojiSkinTone: Identifiable {
+ let id: Int
+ let label: String
+ let color: UIColor
+}
+
+let nativeEmojiSkinTones = [
+ NativeEmojiSkinTone(
+ id: 0,
+ label: "Default",
+ color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 1,
+ label: "Light",
+ color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 2,
+ label: "Medium-light",
+ color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 3,
+ label: "Medium",
+ color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 4,
+ label: "Medium-dark",
+ color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1)
+ ),
+ NativeEmojiSkinTone(
+ id: 5,
+ label: "Dark",
+ color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1)
+ ),
+]
+
+func validNativeEmojiSkinTone(_ value: Int) -> Int {
+ nativeEmojiSkinTones.indices.contains(value) ? value : 0
+}
+
+struct NativeEmojiSection: Identifiable {
+ let id: String
+ let title: String
+ let systemImage: String
+ let items: [NativeEmojiItem]
+}
+
+struct NativeEmojiPickerData {
+ let sections: [NativeEmojiSection]
+ let standardItems: [NativeEmojiItem]
+ let customItems: [NativeEmojiItem]
+}
+
+enum NativeEmojiPickerDataLoader {
+ static let assetPath = "assets/emoji/emoji-data.json"
+
+ static func load(arguments: [String: Any]) -> NativeEmojiPickerData? {
+ let key = FlutterDartProject.lookupKey(forAsset: assetPath)
+ let url = Bundle.main.bundleURL.appendingPathComponent(key)
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ return parse(data: data, arguments: arguments)
+ }
+
+ static func parse(
+ data: Data,
+ arguments: [String: Any]
+ ) -> NativeEmojiPickerData? {
+ guard
+ let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let rawCategories = root["categories"] as? [[String: Any]],
+ let rawEmoji = root["emoji"] as? [String: Any]
+ else {
+ return nil
+ }
+
+ var sections: [NativeEmojiSection] = []
+ var standardItems: [NativeEmojiItem] = []
+ var byValue: [String: NativeEmojiItem] = [:]
+
+ for category in rawCategories {
+ guard
+ let categoryID = category["id"] as? String,
+ let emojiIDs = category["emoji"] as? [String]
+ else {
+ continue
+ }
+
+ var items: [NativeEmojiItem] = []
+ for emojiID in emojiIDs {
+ guard let record = rawEmoji[emojiID] as? [String: Any] else { continue }
+ let name = record["n"] as? String ?? emojiID
+ let keywords = record["k"] as? [String] ?? []
+ let glyphs: [String]
+ if let values = record["u"] as? [String] {
+ glyphs = values
+ } else if let value = record["u"] as? String {
+ glyphs = [value]
+ } else {
+ glyphs = []
+ }
+
+ guard let defaultGlyph = glyphs.first else { continue }
+ let item = NativeEmojiItem(
+ id: emojiID,
+ shortcode: emojiID,
+ value: defaultGlyph,
+ name: name,
+ keywords: keywords,
+ glyph: defaultGlyph,
+ skinVariants: glyphs,
+ imageURL: nil
+ )
+ items.append(item)
+ standardItems.append(item)
+ for glyph in glyphs where byValue[glyph] == nil {
+ byValue[glyph] = item
+ }
+ }
+
+ sections.append(
+ NativeEmojiSection(
+ id: categoryID,
+ title: categoryTitle(categoryID),
+ systemImage: categorySymbol(categoryID),
+ items: items
+ )
+ )
+ }
+
+ let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? []
+ let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in
+ guard
+ let shortcode = raw["shortcode"] as? String,
+ let urlString = raw["url"] as? String,
+ let url = URL(string: urlString)
+ else {
+ return nil
+ }
+ return NativeEmojiItem(
+ id: "custom-\(shortcode)",
+ shortcode: shortcode,
+ value: ":\(shortcode):",
+ name: shortcode,
+ keywords: [],
+ glyph: nil,
+ skinVariants: [],
+ imageURL: url
+ )
+ }
+ let customByValue = Dictionary(
+ customItems.map { ($0.value, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+
+ let recentValues = arguments["recent"] as? [String] ?? []
+ var seenRecentIDs: Set = []
+ let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in
+ guard let item = byValue[value] ?? customByValue[value] else { return nil }
+ return seenRecentIDs.insert(item.id).inserted ? item : nil
+ }
+ if !recentItems.isEmpty {
+ sections.insert(
+ NativeEmojiSection(
+ id: "frequent",
+ title: "Frequently used",
+ systemImage: "clock",
+ items: recentItems
+ ),
+ at: 0
+ )
+ }
+
+ if !customItems.isEmpty {
+ sections.append(
+ NativeEmojiSection(
+ id: "custom",
+ title: "Custom",
+ systemImage: "sparkles",
+ items: customItems
+ )
+ )
+ }
+
+ return NativeEmojiPickerData(
+ sections: sections,
+ standardItems: standardItems,
+ customItems: customItems
+ )
+ }
+
+ private static func categoryTitle(_ id: String) -> String {
+ switch id {
+ case "people": return "Smileys & People"
+ case "nature": return "Animals & Nature"
+ case "foods": return "Food & Drink"
+ case "activity": return "Activity"
+ case "places": return "Travel & Places"
+ case "objects": return "Objects"
+ case "symbols": return "Symbols"
+ case "flags": return "Flags"
+ default: return id.capitalized
+ }
+ }
+
+ private static func categorySymbol(_ id: String) -> String {
+ switch id {
+ case "people": return "face.smiling"
+ case "nature": return "leaf"
+ case "foods": return "fork.knife"
+ case "activity": return "figure.run"
+ case "places": return "airplane"
+ case "objects": return "lightbulb"
+ case "symbols": return "heart"
+ case "flags": return "flag"
+ default: return "circle.grid.3x3"
+ }
+ }
+}
+
+private struct NativeEmojiSearchScore: Comparable {
+ let tier: Int
+ let detail: Int
+ let length: Int
+ let code: String
+
+ static func < (lhs: Self, rhs: Self) -> Bool {
+ if lhs.tier != rhs.tier { return lhs.tier < rhs.tier }
+ if lhs.detail != rhs.detail { return lhs.detail < rhs.detail }
+ if lhs.length != rhs.length { return lhs.length < rhs.length }
+ return lhs.code < rhs.code
+ }
+}
+
+enum NativeEmojiSearch {
+ static func results(
+ query: String,
+ items: [NativeEmojiItem]
+ ) -> [NativeEmojiItem] {
+ items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in
+ guard let score = score(query: query, item: item) else { return nil }
+ return (item, score)
+ }
+ .sorted { $0.1 < $1.1 }
+ .map(\.0)
+ }
+
+ private static func score(
+ query: String,
+ item: NativeEmojiItem
+ ) -> NativeEmojiSearchScore? {
+ let normalizedQuery = collapse(query)
+ guard !normalizedQuery.isEmpty else { return nil }
+ let code = item.shortcode.lowercased()
+ let normalizedCode = collapse(code)
+
+ if normalizedCode == normalizedQuery {
+ return makeScore(tier: 0, detail: 0, code: code)
+ }
+ if normalizedCode.hasPrefix(normalizedQuery) {
+ return makeScore(tier: 1, detail: 0, code: code)
+ }
+
+ let words = ([item.name] + item.keywords)
+ .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) }
+ .map(String.init)
+ if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) {
+ return makeScore(tier: 2, detail: index, code: code)
+ }
+ if let range = normalizedCode.range(of: normalizedQuery) {
+ return makeScore(
+ tier: 3,
+ detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound),
+ code: code
+ )
+ }
+ if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) {
+ return makeScore(tier: 4, detail: index, code: code)
+ }
+ if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) {
+ return makeScore(tier: 5, detail: span, code: code)
+ }
+ return nil
+ }
+
+ private static func makeScore(
+ tier: Int,
+ detail: Int,
+ code: String
+ ) -> NativeEmojiSearchScore {
+ NativeEmojiSearchScore(
+ tier: tier,
+ detail: detail,
+ length: code.count,
+ code: code
+ )
+ }
+
+ private static func collapse(_ value: String) -> String {
+ value.lowercased().filter { !":_ -\t\n".contains($0) }
+ }
+
+ private static func subsequenceSpan(_ query: String, in target: String) -> Int? {
+ let queryCharacters = Array(query)
+ guard !queryCharacters.isEmpty else { return nil }
+ var queryIndex = 0
+ var first: Int?
+ var last = 0
+ for (targetIndex, character) in target.enumerated() {
+ guard character == queryCharacters[queryIndex] else { continue }
+ if first == nil { first = targetIndex }
+ last = targetIndex
+ queryIndex += 1
+ if queryIndex == queryCharacters.count {
+ return last - (first ?? last)
+ }
+ }
+ return nil
+ }
+}
+
+/// The top offset of each pinned section header, keyed by section id, reported
+/// up from the scrolling grid so the rail can follow manual scrolling.
+///
+/// The same stream also carries two viewport measurements under the reserved
+/// keys below, so the tracker sees the section offsets and the viewport bounds
+/// consistently in a single update. Section ids come from the emoji dataset and
+/// never collide with these dotted reserved keys.
+let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom"
+let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom"
+
+struct NativeEmojiSectionOffsetsKey: PreferenceKey {
+ static let defaultValue: [String: CGFloat] = [:]
+
+ static func reduce(
+ value: inout [String: CGFloat],
+ nextValue: () -> [String: CGFloat]
+ ) {
+ value.merge(nextValue(), uniquingKeysWith: { _, next in next })
+ }
+}
+
+/// Pure selection logic: the highlighted section is the last one whose header
+/// has scrolled to or above the top of the viewport. Extracted so the
+/// scroll-tracking behaviour can be unit-tested without a live scroll view.
+enum NativeEmojiCategoryTracker {
+ static func selectedSectionID(
+ order: [String],
+ offsets: [String: CGFloat],
+ viewportTop: CGFloat,
+ viewportBottom: CGFloat? = nil,
+ contentBottom: CGFloat? = nil
+ ) -> String? {
+ // At the clamped bottom of an overflowing list, a final section shorter
+ // than the viewport can never scroll its header to the top, so the
+ // header-at-top rule would keep the preceding section highlighted while the
+ // user is plainly viewing the last one. Detect that case first: the content
+ // end is on screen (`contentBottom <= viewportBottom`) and the top has
+ // scrolled away (`firstTop < viewportTop`, so the list really did overflow
+ // rather than merely fitting). Highlight the last section then.
+ if let viewportBottom,
+ let contentBottom,
+ contentBottom <= viewportBottom + 1,
+ let firstID = order.first,
+ let firstTop = offsets[firstID],
+ firstTop < viewportTop,
+ let lastID = order.last
+ {
+ return lastID
+ }
+
+ var selected: String?
+ for id in order {
+ guard let top = offsets[id] else { continue }
+ // A small tolerance keeps the header that is flush with the top pinned as
+ // selected rather than flickering to the next section.
+ if top <= viewportTop + 1 {
+ selected = id
+ } else {
+ break
+ }
+ }
+ return selected ?? order.first
+ }
+}
diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift
new file mode 100644
index 00000000000..a71de4b815a
--- /dev/null
+++ b/mobile/ios/Runner/NativeEmojiPickerView.swift
@@ -0,0 +1,596 @@
+import ImageIO
+import SwiftUI
+import UIKit
+
+struct NativeEmojiPickerView: View {
+ let data: NativeEmojiPickerData
+ let appearance: NativeEmojiPickerAppearance
+ let onSelect: (String) -> Void
+ let onSkinToneChanged: (Int) -> Void
+ let onClose: () -> Void
+
+ @State private var query = ""
+ @State private var selectedSectionID: String?
+ @State private var selectedSkinTone: Int
+
+ private let columns = Array(
+ repeating: GridItem(.flexible(minimum: 36), spacing: 0),
+ count: 8
+ )
+
+ private let sectionListSpace = "buzz.emoji.sectionList"
+
+ init(
+ data: NativeEmojiPickerData,
+ appearance: NativeEmojiPickerAppearance,
+ initialSkinTone: Int,
+ onSelect: @escaping (String) -> Void,
+ onSkinToneChanged: @escaping (Int) -> Void,
+ onClose: @escaping () -> Void
+ ) {
+ self.data = data
+ self.appearance = appearance
+ self.onSelect = onSelect
+ self.onSkinToneChanged = onSkinToneChanged
+ self.onClose = onClose
+ _selectedSkinTone = State(
+ initialValue: validNativeEmojiSkinTone(initialSkinTone)
+ )
+ }
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ VStack(spacing: 0) {
+ header
+ if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ categoryRail(proxy)
+ }
+ Divider().overlay(Color(uiColor: appearance.divider))
+ pickerContent
+ }
+ .background(Color(uiColor: appearance.surface))
+ .onAppear {
+ selectedSectionID = data.sections.first?.id
+ }
+ }
+ }
+
+ private var header: some View {
+ HStack(spacing: 8) {
+ HStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 17, weight: .medium))
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ TextField("Search emoji", text: $query)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled(true)
+ .submitLabel(.search)
+ .foregroundStyle(Color(uiColor: appearance.text))
+ if !query.isEmpty {
+ Button {
+ query = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Clear search")
+ }
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 44)
+ .background(Color(uiColor: appearance.control), in: Capsule())
+ .overlay {
+ Capsule()
+ .stroke(Color(uiColor: appearance.divider), lineWidth: 1)
+ }
+
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(Color(uiColor: appearance.text))
+ .frame(width: 44, height: 44)
+ .background(Color(uiColor: appearance.control), in: Circle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Close sheet")
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 16)
+ .padding(.bottom, 8)
+ }
+
+ private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
+ HStack(spacing: 0) {
+ ForEach(data.sections) { section in
+ Button {
+ selectedSectionID = section.id
+ withAnimation(.easeOut(duration: 0.24)) {
+ proxy.scrollTo("section-\(section.id)", anchor: .top)
+ }
+ } label: {
+ Image(systemName: section.systemImage)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(
+ Color(
+ uiColor: selectedSectionID == section.id
+ ? appearance.accent : appearance.secondaryText
+ )
+ )
+ .frame(maxWidth: .infinity)
+ .frame(height: 36)
+ .background(
+ selectedSectionID == section.id
+ ? Color(uiColor: appearance.control) : Color.clear,
+ in: Circle()
+ )
+ }
+ .frame(maxWidth: .infinity)
+ .buttonStyle(.plain)
+ .accessibilityLabel(section.title)
+ .accessibilityAddTraits(
+ selectedSectionID == section.id ? .isSelected : []
+ )
+ }
+ Divider()
+ .frame(height: 24)
+ .overlay(Color(uiColor: appearance.divider))
+ skinToneSelector
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 44)
+ }
+
+ private var skinToneSelector: some View {
+ Menu {
+ ForEach(nativeEmojiSkinTones) { tone in
+ Button {
+ selectedSkinTone = tone.id
+ onSkinToneChanged(tone.id)
+ } label: {
+ Label {
+ Text(tone.label)
+ } icon: {
+ Image(uiImage: skinTonePreviewImage(tone))
+ .renderingMode(.original)
+ }
+ }
+ }
+ } label: {
+ skinToneDot(nativeEmojiSkinTones[selectedSkinTone])
+ .frame(maxWidth: .infinity)
+ .frame(height: 36)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Skin tone")
+ }
+
+ private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View {
+ Circle()
+ .fill(Color(uiColor: tone.color))
+ .frame(width: 16, height: 16)
+ .overlay {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.white.opacity(0.2), .clear],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ .blendMode(.overlay)
+ }
+ .overlay {
+ Circle().stroke(.black.opacity(0.8), lineWidth: 1)
+ }
+ }
+
+ private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage {
+ let size = CGSize(width: 16, height: 16)
+ return UIGraphicsImageRenderer(size: size).image { rendererContext in
+ let context = rendererContext.cgContext
+ let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
+ let circle = UIBezierPath(ovalIn: rect)
+
+ tone.color.setFill()
+ circle.fill()
+
+ if let gradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: [
+ UIColor.white.withAlphaComponent(0.2).cgColor,
+ UIColor.clear.cgColor,
+ ] as CFArray,
+ locations: [0, 1]
+ ) {
+ context.saveGState()
+ circle.addClip()
+ context.setBlendMode(.overlay)
+ context.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: size.width / 2, y: 0),
+ end: CGPoint(x: size.width / 2, y: size.height),
+ options: []
+ )
+ context.restoreGState()
+ }
+
+ UIColor.black.withAlphaComponent(0.8).setStroke()
+ circle.lineWidth = 1
+ circle.stroke()
+ }
+ }
+
+ @ViewBuilder
+ private var pickerContent: some View {
+ let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedQuery.isEmpty {
+ sectionList(data.sections, tracksSelection: true)
+ } else {
+ let custom = NativeEmojiSearch.results(
+ query: trimmedQuery,
+ items: data.customItems
+ )
+ let standard = NativeEmojiSearch.results(
+ query: trimmedQuery,
+ items: data.standardItems
+ )
+ let sections = [
+ NativeEmojiSection(
+ id: "search-custom",
+ title: "Custom",
+ systemImage: "sparkles",
+ items: custom
+ ),
+ NativeEmojiSection(
+ id: "search-standard",
+ title: "Emoji",
+ systemImage: "face.smiling",
+ items: standard
+ ),
+ ].filter { !$0.items.isEmpty }
+
+ if sections.isEmpty {
+ VStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 28))
+ Text("No emoji found").font(.body)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ } else {
+ sectionList(sections, tracksSelection: false)
+ }
+ }
+ }
+
+ private func sectionList(
+ _ sections: [NativeEmojiSection],
+ tracksSelection: Bool
+ ) -> some View {
+ ScrollView {
+ LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
+ ForEach(sections) { section in
+ Section {
+ LazyVGrid(columns: columns, spacing: 0) {
+ ForEach(section.items) { item in
+ emojiButton(item)
+ }
+ }
+ .padding(.horizontal, 16)
+ } header: {
+ HStack {
+ Text(section.title)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(Color(uiColor: appearance.secondaryText))
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 30)
+ .background(Color(uiColor: appearance.surface))
+ .background(sectionOffsetReporter(id: section.id))
+ .id("section-\(section.id)")
+ }
+ }
+ }
+ .padding(.bottom, 8)
+ .background(contentBoundaryReporter())
+ }
+ .coordinateSpace(name: sectionListSpace)
+ .background(viewportBoundaryReporter())
+ .scrollDismissesKeyboard(.interactively)
+ .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
+ guard tracksSelection else { return }
+ selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
+ order: data.sections.map(\.id),
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: offsets[nativeEmojiViewportBottomKey],
+ contentBottom: offsets[nativeEmojiContentBottomKey]
+ )
+ }
+ }
+
+ private func sectionOffsetReporter(id: String) -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
+ )
+ }
+ }
+
+ // The end of the scrolling content, relative to the viewport top. At the
+ // clamped bottom of an overflowing list this converges on the viewport
+ // height, which lets the tracker highlight a short final section that can
+ // never scroll its own header to the top.
+ private func contentBoundaryReporter() -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [
+ nativeEmojiContentBottomKey:
+ geometry.frame(in: .named(sectionListSpace)).maxY
+ ]
+ )
+ }
+ }
+
+ // The fixed viewport height, reported through the same preference stream so
+ // it stays consistent with the section offsets in each update.
+ private func viewportBoundaryReporter() -> some View {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: NativeEmojiSectionOffsetsKey.self,
+ value: [nativeEmojiViewportBottomKey: geometry.size.height]
+ )
+ }
+ }
+
+ private func emojiButton(_ item: NativeEmojiItem) -> some View {
+ let value = displayValue(for: item)
+ return Button {
+ onSelect(value)
+ } label: {
+ Group {
+ if let url = item.imageURL {
+ NativeEmojiRemoteImage(
+ url: url,
+ fallbackColor: appearance.secondaryText
+ )
+ .frame(width: 28, height: 28)
+ } else {
+ Text(value).font(.system(size: 28))
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 44)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(item.name)
+ }
+
+ private func displayValue(for item: NativeEmojiItem) -> String {
+ guard item.imageURL == nil else { return item.value }
+ guard item.skinVariants.indices.contains(selectedSkinTone) else {
+ return item.skinVariants.first ?? item.value
+ }
+ return item.skinVariants[selectedSkinTone]
+ }
+}
+
+struct NativeEmojiRemoteImage: View {
+ let url: URL
+ let fallbackColor: UIColor
+
+ @State private var phase: Phase = .loading
+
+ private enum Phase {
+ case loading
+ case success(UIImage)
+ case failure
+ }
+
+ var body: some View {
+ Group {
+ switch phase {
+ case .loading:
+ ProgressView().controlSize(.mini)
+ case .success(let image):
+ Image(uiImage: image).resizable().scaledToFit()
+ case .failure:
+ Image(systemName: "sparkles")
+ .foregroundStyle(Color(uiColor: fallbackColor))
+ }
+ }
+ .task(id: requestIdentity) {
+ do {
+ let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders(
+ for: url
+ )
+ var request = URLRequest(url: url)
+ for (name, value) in requestHeaders {
+ request.setValue(value, forHTTPHeaderField: name)
+ }
+ phase = .success(
+ try await NativeEmojiRemoteImageLoader.shared.image(for: request)
+ )
+ } catch {
+ if !Task.isCancelled { phase = .failure }
+ }
+ }
+ }
+
+ private var requestIdentity: String {
+ url.absoluteString
+ }
+}
+
+enum NativeEmojiRemoteImageError: Error {
+ case invalidResponse
+ case responseTooLarge
+ case invalidImage
+}
+
+actor NativeEmojiRemoteImageLoader {
+ typealias Downloader = (URLRequest) async throws -> UIImage
+
+ static let shared = NativeEmojiRemoteImageLoader()
+ static let defaultMaximumConcurrentDownloads = 4
+
+ private static let maximumDownloadBytes = 10 * 1024 * 1024
+ private static let maximumThumbnailPixels = 84
+ private static let defaultCacheByteLimit = 8 * 1024 * 1024
+
+ private struct Waiter {
+ let id: UUID
+ let continuation: CheckedContinuation
+ }
+
+ private let maximumConcurrentDownloads: Int
+ private let downloader: Downloader
+ private let admissionAttemptForTesting: (() -> Void)?
+ private let cache = NSCache()
+ private var activeDownloadCount = 0
+ private var waiters: [Waiter] = []
+
+ init(
+ maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads,
+ cacheByteLimit: Int = defaultCacheByteLimit,
+ admissionAttemptForTesting: (() -> Void)? = nil,
+ downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download
+ ) {
+ precondition(maximumConcurrentDownloads > 0)
+ precondition(cacheByteLimit >= 0)
+ self.maximumConcurrentDownloads = maximumConcurrentDownloads
+ self.admissionAttemptForTesting = admissionAttemptForTesting
+ self.downloader = downloader
+ cache.totalCostLimit = cacheByteLimit
+ }
+
+ func image(for request: URLRequest) async throws -> UIImage {
+ let cacheKey = request as NSURLRequest
+ if let cached = cache.object(forKey: cacheKey) {
+ return cached
+ }
+
+ recordAdmissionAttemptForTesting()
+ try await acquireDownloadSlot()
+ defer { releaseDownloadSlot() }
+
+ try Task.checkCancellation()
+ if let cached = cache.object(forKey: cacheKey) {
+ return cached
+ }
+
+ let image = try await downloader(request)
+ cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image))
+ return image
+ }
+
+ private func recordAdmissionAttemptForTesting() {
+ admissionAttemptForTesting?()
+ }
+
+ private func acquireDownloadSlot() async throws {
+ try Task.checkCancellation()
+ guard activeDownloadCount >= maximumConcurrentDownloads else {
+ activeDownloadCount += 1
+ return
+ }
+
+ let waiterID = UUID()
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation) in
+ if Task.isCancelled {
+ continuation.resume(throwing: CancellationError())
+ } else {
+ waiters.append(Waiter(id: waiterID, continuation: continuation))
+ }
+ }
+ } onCancel: {
+ Task { await self.cancelWaiter(id: waiterID) }
+ }
+ }
+
+ private func cancelWaiter(id: UUID) {
+ guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }
+ let waiter = waiters.remove(at: index)
+ waiter.continuation.resume(throwing: CancellationError())
+ }
+
+ private func releaseDownloadSlot() {
+ while !waiters.isEmpty {
+ let waiter = waiters.removeFirst()
+ waiter.continuation.resume()
+ return
+ }
+ activeDownloadCount -= 1
+ }
+
+ private static func download(_ request: URLRequest) async throws -> UIImage {
+ let (bytes, response) = try await URLSession.shared.bytes(for: request)
+ guard
+ let httpResponse = response as? HTTPURLResponse,
+ (200..<300).contains(httpResponse.statusCode)
+ else {
+ throw NativeEmojiRemoteImageError.invalidResponse
+ }
+ if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
+ let byteCount = Int(contentLength),
+ byteCount > maximumDownloadBytes
+ {
+ throw NativeEmojiRemoteImageError.responseTooLarge
+ }
+
+ var data = Data()
+ let expected = httpResponse.expectedContentLength
+ if expected > 0 {
+ data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes))))
+ }
+ for try await byte in bytes {
+ guard data.count < maximumDownloadBytes else {
+ throw NativeEmojiRemoteImageError.responseTooLarge
+ }
+ data.append(byte)
+ }
+ try Task.checkCancellation()
+ guard let image = thumbnail(from: data) else {
+ throw NativeEmojiRemoteImageError.invalidImage
+ }
+ return image
+ }
+
+ private static func thumbnail(from data: Data) -> UIImage? {
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
+ return nil
+ }
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels,
+ kCGImageSourceShouldCacheImmediately: true,
+ ]
+ guard
+ let image = CGImageSourceCreateThumbnailAtIndex(
+ source,
+ 0,
+ options as CFDictionary
+ )
+ else {
+ return nil
+ }
+ return UIImage(cgImage: image)
+ }
+
+ private static func cacheCost(for image: UIImage) -> Int {
+ guard let cgImage = image.cgImage else { return 0 }
+ let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow(
+ by: cgImage.height
+ )
+ return overflow ? Int.max : cost
+ }
+}
diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift
new file mode 100644
index 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/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/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift
index e1c2ce00f62..e4d56c4ed5a 100644
--- a/mobile/ios/RunnerTests/RunnerTests.swift
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -403,6 +403,324 @@ class RunnerTests: XCTestCase {
}
}
+ func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() {
+ let order = ["people", "nature", "flags"]
+ let offsets: [String: CGFloat] = [
+ "people": -320,
+ "nature": -12,
+ "flags": 200,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0
+ ),
+ "nature"
+ )
+ }
+
+ func testCategoryTrackerFollowsScrollPastEachHeader() {
+ let order = ["people", "nature", "flags"]
+
+ // Scrolled to the very top: the first section is highlighted.
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: ["people": 0, "nature": 400, "flags": 800],
+ viewportTop: 0
+ ),
+ "people"
+ )
+
+ // Scrolled far enough that Flags has reached the top.
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: ["people": -800, "nature": -400, "flags": 0],
+ viewportTop: 0
+ ),
+ "flags"
+ )
+ }
+
+ func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() {
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: ["people", "nature"],
+ offsets: ["people": 40, "nature": 400],
+ viewportTop: 0
+ ),
+ "people"
+ )
+ }
+
+ func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() {
+ // The list has overflowed (People scrolled above the top) and its end is on
+ // screen, but the short Custom section's header sits below the top because
+ // the content clamps before it can reach it. The rail must still highlight
+ // Custom rather than leaving Nature — its predecessor — selected.
+ let order = ["people", "nature", "custom"]
+ let offsets: [String: CGFloat] = [
+ "people": -900,
+ "nature": -420,
+ "custom": 360,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 500
+ ),
+ "custom"
+ )
+ }
+
+ func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() {
+ // The same short-final geometry, but the content end is still below the
+ // viewport (the user has not reached the bottom), so the ordinary
+ // header-at-top rule applies and Nature stays selected.
+ let order = ["people", "nature", "custom"]
+ let offsets: [String: CGFloat] = [
+ "people": -900,
+ "nature": -420,
+ "custom": 360,
+ ]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 900
+ ),
+ "nature"
+ )
+ }
+
+ func testCategoryTrackerDoesNotForceLastSectionForAShortList() {
+ // A list that fits without scrolling has its content end on screen too, but
+ // its first header is still at the top — so the bottom rule must not fire
+ // and steal the highlight to the final section.
+ let order = ["people", "nature"]
+ let offsets: [String: CGFloat] = ["people": 0, "nature": 120]
+
+ XCTAssertEqual(
+ NativeEmojiCategoryTracker.selectedSectionID(
+ order: order,
+ offsets: offsets,
+ viewportTop: 0,
+ viewportBottom: 500,
+ contentBottom: 240
+ ),
+ "people"
+ )
+ }
+
+ func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
+ let maximumConcurrentDownloads = 3
+ let taskCount = 8
+ let probe = NativeEmojiDownloadProbe()
+ let tasksAttemptedAdmission = XCTestExpectation(
+ description: "all download tasks attempted admission"
+ )
+ tasksAttemptedAdmission.expectedFulfillmentCount = taskCount
+ let loader = NativeEmojiRemoteImageLoader(
+ maximumConcurrentDownloads: maximumConcurrentDownloads,
+ cacheByteLimit: 0,
+ admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() },
+ downloader: { _ in
+ await probe.holdDownload()
+ return UIImage()
+ }
+ )
+ let tasks = (0.. UIImage {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
@@ -630,3 +948,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
}
+
+private actor NativeEmojiDownloadProbe {
+ private struct MilestoneWaiter {
+ let count: Int
+ let continuation: CheckedContinuation
+ }
+
+ private var active = 0
+ private var peakActive = 0
+ private var started = 0
+ private var releaseContinuations: [CheckedContinuation] = []
+ private var milestoneWaiters: [MilestoneWaiter] = []
+
+ func holdDownload() async {
+ active += 1
+ started += 1
+ peakActive = max(peakActive, active)
+ resumeReachedMilestones()
+ await withCheckedContinuation { continuation in
+ releaseContinuations.append(continuation)
+ }
+ active -= 1
+ }
+
+ func waitUntilStarted(_ count: Int) async {
+ guard started < count else { return }
+ await withCheckedContinuation { continuation in
+ milestoneWaiters.append(
+ MilestoneWaiter(count: count, continuation: continuation)
+ )
+ }
+ }
+
+ func releaseOne() {
+ guard !releaseContinuations.isEmpty else { return }
+ releaseContinuations.removeFirst().resume()
+ }
+
+ func releaseAll() {
+ let continuations = releaseContinuations
+ releaseContinuations.removeAll()
+ for continuation in continuations {
+ continuation.resume()
+ }
+ }
+
+ func snapshot() -> (active: Int, peakActive: Int, started: Int) {
+ (active, peakActive, started)
+ }
+
+ private func resumeReachedMilestones() {
+ let reached = milestoneWaiters.filter { $0.count <= started }
+ milestoneWaiters.removeAll { $0.count <= started }
+ for waiter in reached {
+ waiter.continuation.resume()
+ }
+ }
+}
diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart
index dcae998ed0f..62dfb18fc79 100644
--- a/mobile/lib/features/activity/activity_page.dart
+++ b/mobile/lib/features/activity/activity_page.dart
@@ -220,6 +220,8 @@ class ActivityPage extends HookConsumerWidget {
channel: channel,
initialMessageId: target.id,
initialThreadRootId: threadRootId,
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
@@ -240,6 +242,8 @@ class ActivityPage extends HookConsumerWidget {
builder: (_) => ChannelDetailPage(
channel: channel,
initialThreadRootId: draft.threadHeadId,
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
),
),
);
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 8cef1a78ff7..a36b951e420 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';
@@ -122,21 +123,36 @@ int? _channelReadTimestamp({
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
+/// Controls how a hydrated initial thread is added to the navigation stack.
+enum InitialThreadRouteBehavior {
+ /// Keep the channel route beneath the thread.
+ push,
+
+ /// Replace the temporary channel route so Back returns to its origin.
+ replaceCurrentRoute,
+}
+
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
final String? initialMessageId;
final String? initialThreadRootId;
+ /// How the automatically opened initial thread affects the route stack.
+ final InitialThreadRouteBehavior initialThreadRouteBehavior;
+
const ChannelDetailPage({
super.key,
required this.channel,
this.initialMessageId,
this.initialThreadRootId,
+ this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final composerDockHeight = useState(0.0);
+ final composerFocusNode = useFocusNode();
+ final restoreComposerFocus = useRef(null);
final sendMessage = ref.read(sendMessageProvider);
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
@@ -450,6 +466,8 @@ class ChannelDetailPage extends HookConsumerWidget {
allMessages: messages,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
+ initialThreadRouteBehavior:
+ initialThreadRouteBehavior,
initialOrdinaryUnreadMessageIds:
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId:
@@ -472,6 +490,12 @@ class ChannelDetailPage extends HookConsumerWidget {
composerBottomInset: showsComposer
? composerDockHeight.value
: 0,
+ composerFocusNode: showsComposer
+ ? composerFocusNode
+ : null,
+ restoreComposerFocus: showsComposer
+ ? () => restoreComposerFocus.value?.call()
+ : null,
);
},
),
@@ -520,6 +544,9 @@ class ChannelDetailPage extends HookConsumerWidget {
),
ComposeBar(
channelId: channel.id,
+ focusNode: composerFocusNode,
+ onFocusRestorerChanged: (restoreFocus) =>
+ restoreComposerFocus.value = restoreFocus,
channelName: resolvedChannel.isDm
? ''
: resolvedChannel.name,
diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
index 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 57a621fbaf6..06308063ab0 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -5,6 +5,7 @@ class _MessageList extends HookConsumerWidget {
final List allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
+ final InitialThreadRouteBehavior initialThreadRouteBehavior;
final Set initialOrdinaryUnreadMessageIds;
final String? initialOldestOrdinaryUnreadMessageId;
final Set initialForcedUnreadMessageIds;
@@ -15,12 +16,15 @@ class _MessageList extends HookConsumerWidget {
final bool isArchived;
final double appBarTitleContentHeight;
final double composerBottomInset;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
const _MessageList({
required this.entries,
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
+ required this.initialThreadRouteBehavior,
required this.initialOrdinaryUnreadMessageIds,
required this.initialOldestOrdinaryUnreadMessageId,
required this.initialForcedUnreadMessageIds,
@@ -31,6 +35,8 @@ class _MessageList extends HookConsumerWidget {
required this.isArchived,
required this.appBarTitleContentHeight,
required this.composerBottomInset,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
@@ -39,6 +45,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 +57,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 +79,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 +91,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 +218,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 +346,7 @@ class _MessageList extends HookConsumerWidget {
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
+ isJumpToLatestVisible.value = false;
}
} finally {
isAutoScrolling.value = false;
@@ -203,6 +358,7 @@ class _MessageList extends HookConsumerWidget {
isAutoScrolling.value = true;
followsLatest.value = true;
hasUserScrolled.value = false;
+ hasUnseenLatestEntry.value = false;
latestNavigationRequest.value += 1;
}
@@ -253,6 +409,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 +459,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
@@ -355,23 +571,30 @@ class _MessageList extends HookConsumerWidget {
if (threadHead == null) return null;
didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!context.mounted) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ThreadDetailPage(
- threadHead: threadHead,
- allMessages: allMessages,
- channelId: channelId,
- currentPubkey: currentPubkey,
- isMember: isMember,
- isArchived: isArchived,
- initialMessageId: initialMessageId,
- ),
+ if (!context.mounted || ModalRoute.of(context)?.isCurrent != true) {
+ return;
+ }
+ final route = MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: allMessages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ initialMessageId: initialMessageId,
),
);
+ final navigator = Navigator.of(context);
+ switch (initialThreadRouteBehavior) {
+ case InitialThreadRouteBehavior.push:
+ navigator.push(route);
+ case InitialThreadRouteBehavior.replaceCurrentRoute:
+ navigator.pushReplacement(route);
+ }
});
return null;
- }, [initialThreadRootId, allMessages]);
+ }, [initialThreadRootId, allMessages, initialThreadRouteBehavior]);
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
@@ -396,12 +619,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 +682,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 +788,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,
@@ -553,6 +817,8 @@ class _MessageList extends HookConsumerWidget {
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
),
if (entry.summary != null)
_ThreadSummaryRow(
@@ -572,6 +838,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 +876,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 +915,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/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/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/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart
index e8e62199d70..b66d526e7ee 100644
--- a/mobile/lib/features/channels/emoji_picker.dart
+++ b/mobile/lib/features/channels/emoji_picker.dart
@@ -1,4 +1,8 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -10,17 +14,19 @@ import '../../shared/emoji/emoji_data.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_search.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
+import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
+import '../../shared/widgets/buzz_sheet_header.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'recent_emoji_provider.dart';
part 'emoji_picker/search_field.dart';
part 'emoji_picker/category_rail.dart';
part 'emoji_picker/emoji_grid.dart';
+part 'emoji_picker/ios_native_picker.dart';
-/// Height of the picker sheet as a fraction of the screen. The full emoji set
-/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked
-/// subset and had no room to browse.
+/// Android keeps the established Flutter tray height. iOS is presented by a
+/// native sheet with system detents in [ios_native_picker.dart].
const _sheetHeightFactor = 0.62;
/// Opens the full emoji picker as a modal bottom sheet.
@@ -33,12 +39,34 @@ void showEmojiPicker({
required BuildContext context,
required void Function(String emoji) onSelect,
VoidCallback? onDismiss,
+}) {
+ if (defaultTargetPlatform == TargetPlatform.iOS) {
+ unawaited(
+ _presentIosEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ ),
+ );
+ return;
+ }
+
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+}
+
+void _showFlutterEmojiPicker({
+ required BuildContext context,
+ required void Function(String emoji) onSelect,
+ VoidCallback? onDismiss,
}) {
showBuzzModalBottomSheet(
context: context,
isScrollControlled: true,
- showDragHandle: true,
- backgroundColor: context.colors.surfaceContainerHighest,
+ showCloseButton: false,
builder: (sheetContext) => EmojiPickerSheet(
onSelect: (emoji) {
Navigator.of(sheetContext).pop();
@@ -53,11 +81,41 @@ class EmojiPickerSheet extends HookConsumerWidget {
const EmojiPickerSheet({super.key, required this.onSelect});
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return SizedBox(
+ height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
+ child: _EmojiPickerContent(onSelect: onSelect),
+ );
+ }
+}
+
+class _EmojiPickerContent extends HookConsumerWidget {
+ const _EmojiPickerContent({required this.onSelect});
+
+ final void Function(String emoji) onSelect;
+
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
final customEmoji = ref.watch(customEmojiListProvider);
final recent = ref.watch(recentEmojiProvider);
+ final prefs = ref.read(savedPrefsProvider);
+ final skinTone = useState(
+ _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
+ );
+
+ void selectSkinTone(int value) {
+ final next = _validSkinTone(value);
+ if (skinTone.value == next) return;
+ skinTone.value = next;
+ unawaited(prefs.setInt(_emojiSkinTonePrefsKey, next));
+ }
+
+ final visibleDataset = useMemoized(
+ () => _datasetForSkinTone(dataset, skinTone.value),
+ [dataset, skinTone.value],
+ );
final searchController = useTextEditingController();
final query = useState('');
@@ -74,20 +132,37 @@ class EmojiPickerSheet extends HookConsumerWidget {
final sections = useMemoized(
() => _buildSections(
- dataset: dataset,
+ dataset: visibleDataset,
+ sourceDataset: dataset,
customEmoji: customEmoji,
recent: recent,
onSelect: select,
),
- [dataset, customEmoji, recent],
+ [visibleDataset, dataset, customEmoji, recent],
);
final offsets = useMemoized(() => _sectionOffsets(sections), [sections]);
-
final scrollController = useScrollController();
+
// A notifier rather than state: the highlight changes on every scroll frame
// and only the rail needs to hear about it. Rebuilding the sheet would
// rebuild the grid underneath it.
- final activeSection = useMemoized(() => ValueNotifier(0), [sections]);
+ //
+ // Seed it from the current scroll offset rather than 0: a skin-tone change
+ // rebuilds [sections] and so replaces this notifier, but the grid keeps its
+ // scroll position (same controller, same section extents). Resetting to 0
+ // here would falsely highlight the first category until the next scroll.
+ final activeSection = useMemoized(
+ () => ValueNotifier(
+ scrollController.hasClients
+ ? _activeSectionIndex(
+ offsets,
+ scrollController.offset,
+ maxScrollExtent: scrollController.position.maxScrollExtent,
+ )
+ : 0,
+ ),
+ [sections],
+ );
useEffect(() => activeSection.dispose, [activeSection]);
useEffect(() {
@@ -96,6 +171,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
activeSection.value = _activeSectionIndex(
offsets,
scrollController.offset,
+ maxScrollExtent: scrollController.position.maxScrollExtent,
);
}
@@ -119,9 +195,9 @@ class EmojiPickerSheet extends HookConsumerWidget {
// while the sheet animates.
final results = useMemoized(
() => isSearching
- ? searchEmoji(trimmedQuery, dataset.all)
+ ? searchEmoji(trimmedQuery, visibleDataset.all)
: const [],
- [trimmedQuery, dataset],
+ [trimmedQuery, visibleDataset],
);
final customResults = useMemoized(
() => isSearching
@@ -134,37 +210,48 @@ class EmojiPickerSheet extends HookConsumerWidget {
[trimmedQuery, customEmoji],
);
- return SizedBox(
- height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
- child: Column(
- children: [
- _EmojiSearchField(controller: searchController),
- if (!isSearching && sections.isNotEmpty)
- ValueListenableBuilder(
- valueListenable: activeSection,
- builder: (context, active, _) => _CategoryRail(
- sections: sections,
- activeIndex: active,
- onSelect: jumpToSection,
+ return Column(
+ children: [
+ LayoutBuilder(
+ builder: (context, constraints) => BuzzSheetHeader(
+ showDragHandle: true,
+ leading: SizedBox(
+ width: constraints.maxWidth - Grid.gutter * 2 - 44 - Grid.xxs,
+ child: _EmojiSearchField(
+ controller: searchController,
+ padding: EdgeInsets.zero,
),
),
- Divider(height: 1, color: context.colors.outlineVariant),
- Expanded(
- child: dataset.isEmpty && customEmoji.isEmpty
- ? const Center(child: CircularProgressIndicator())
- : isSearching
- ? _EmojiSearchResults(
- entries: results,
- customEmoji: customResults,
- onSelect: select,
- )
- : _ContinuousEmojiGrid(
- sections: sections,
- controller: scrollController,
- ),
),
- ],
- ),
+ ),
+ if (!isSearching && sections.isNotEmpty)
+ ValueListenableBuilder(
+ valueListenable: activeSection,
+ builder: (context, active, _) => _CategoryRail(
+ sections: sections,
+ activeIndex: active,
+ onSelect: jumpToSection,
+ skinTone: skinTone.value,
+ onSkinToneChanged: selectSkinTone,
+ ),
+ ),
+ Divider(height: 1, color: context.colors.outlineVariant),
+ Expanded(
+ child: dataset.isEmpty && customEmoji.isEmpty
+ ? const Center(child: CircularProgressIndicator())
+ : isSearching
+ ? _EmojiSearchResults(
+ entries: results,
+ customEmoji: customResults,
+ onSelect: select,
+ controller: scrollController,
+ )
+ : _ContinuousEmojiGrid(
+ sections: sections,
+ controller: scrollController,
+ ),
+ ),
+ ],
);
}
}
@@ -177,6 +264,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
/// nowhere.
List<_EmojiSection> _buildSections({
required EmojiDataset dataset,
+ required EmojiDataset sourceDataset,
required List customEmoji,
required List recent,
required void Function(String emoji) onSelect,
@@ -186,6 +274,7 @@ List<_EmojiSection> _buildSections({
final recentTiles = _resolveRecentTiles(
recent: recent,
dataset: dataset,
+ sourceDataset: sourceDataset,
customEmoji: customEmoji,
onSelect: onSelect,
);
@@ -246,15 +335,18 @@ List<_EmojiSection> _buildSections({
List _resolveRecentTiles({
required List recent,
required EmojiDataset dataset,
+ required EmojiDataset sourceDataset,
required List customEmoji,
required void Function(String emoji) onSelect,
}) {
final customByShortcode = {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji,
};
- final entriesByNative = {
- for (final entry in dataset.all) entry.native: entry,
+ final sourceEntriesByNative = {
+ for (final entry in sourceDataset.all) entry.native: entry,
};
+ final visibleEntriesById = {for (final entry in dataset.all) entry.id: entry};
+ final seenStandardIds = {};
final tiles = [];
for (final item in recent) {
@@ -272,7 +364,9 @@ List _resolveRecentTiles({
);
continue;
}
- final entry = entriesByNative[value];
+ final sourceEntry = sourceEntriesByNative[value];
+ if (sourceEntry == null || !seenStandardIds.add(sourceEntry.id)) continue;
+ final entry = visibleEntriesById[sourceEntry.id];
if (entry == null) continue;
tiles.add(
_EmojiTile(
@@ -284,3 +378,37 @@ List _resolveRecentTiles({
}
return tiles;
}
+
+/// Project the dataset to one visible tile per shortcode. Emoji that support
+/// skin tones use the selected variant; everything else keeps its default.
+EmojiDataset _datasetForSkinTone(EmojiDataset dataset, int skinTone) {
+ if (dataset.isEmpty) return dataset;
+ final categories = [];
+ final all = [];
+
+ for (final category in dataset.categories) {
+ final variantsById = >{};
+ for (final entry in category.emoji) {
+ variantsById.putIfAbsent(entry.id, () => []).add(entry);
+ }
+ final visible = [];
+ for (final variants in variantsById.values) {
+ final selected = variants.firstWhere(
+ (entry) => entry.skinIndex == skinTone,
+ orElse: () => variants.firstWhere(
+ (entry) => entry.skinIndex == 0,
+ orElse: () => variants.first,
+ ),
+ );
+ visible.add(selected);
+ all.add(selected);
+ }
+ categories.add(EmojiCategory(id: category.id, emoji: visible));
+ }
+
+ return EmojiDataset(
+ categories: categories,
+ all: all,
+ nativeToShortcode: dataset.nativeToShortcode,
+ );
+}
diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart
index 50df1406886..e654134ec66 100644
--- a/mobile/lib/features/channels/emoji_picker/category_rail.dart
+++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart
@@ -18,6 +18,20 @@ IconData _categoryIcon(String categoryId) => switch (categoryId) {
/// 18px icon it holds.
const _railHeight = 36.0;
+const _emojiSkinTonePrefsKey = 'buzz.emoji-picker.skin-tone.v1';
+
+const _skinTones = [
+ (label: 'Default', color: Color(0xFFFFC93A)),
+ (label: 'Light', color: Color(0xFFFFDAB7)),
+ (label: 'Medium-light', color: Color(0xFFE7B98F)),
+ (label: 'Medium', color: Color(0xFFC88C61)),
+ (label: 'Medium-dark', color: Color(0xFFA46134)),
+ (label: 'Dark', color: Color(0xFF5D4437)),
+];
+
+int _validSkinTone(int? value) =>
+ value != null && value >= 0 && value < _skinTones.length ? value : 0;
+
/// Category selector: one icon per section of the continuous grid, in scroll
/// order. Tapping jumps to that section; scrolling moves the highlight.
///
@@ -28,11 +42,15 @@ class _CategoryRail extends StatelessWidget {
final List<_EmojiSection> sections;
final int activeIndex;
final ValueChanged onSelect;
+ final int skinTone;
+ final ValueChanged onSkinToneChanged;
const _CategoryRail({
required this.sections,
required this.activeIndex,
required this.onSelect,
+ required this.skinTone,
+ required this.onSkinToneChanged,
});
@override
@@ -52,6 +70,12 @@ class _CategoryRail extends StatelessWidget {
onTap: () => onSelect(i),
),
),
+ Expanded(
+ child: _SkinToneSelector(
+ value: skinTone,
+ onChanged: onSkinToneChanged,
+ ),
+ ),
],
),
),
@@ -59,6 +83,99 @@ class _CategoryRail extends StatelessWidget {
}
}
+class _SkinToneSelector extends StatelessWidget {
+ const _SkinToneSelector({required this.value, required this.onChanged});
+
+ final int value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final selected = _skinTones[_validSkinTone(value)];
+ return PopupMenuButton(
+ key: const ValueKey('emoji-skin-tone-selector'),
+ initialValue: value,
+ tooltip: 'Skin tone',
+ position: PopupMenuPosition.under,
+ onSelected: onChanged,
+ itemBuilder: (context) => [
+ for (final (index, tone) in _skinTones.indexed)
+ PopupMenuItem(
+ key: ValueKey('emoji-skin-tone-$index'),
+ value: index,
+ child: Row(
+ children: [
+ _SkinToneDot(
+ key: ValueKey('emoji-skin-tone-dot-$index'),
+ color: tone.color,
+ ),
+ const SizedBox(width: Grid.xs),
+ Expanded(child: Text(tone.label)),
+ if (index == value)
+ Icon(
+ LucideIcons.check,
+ size: 18,
+ color: context.colors.primary,
+ ),
+ ],
+ ),
+ ),
+ ],
+ child: Semantics(
+ button: true,
+ label: 'Skin tone',
+ child: Center(
+ child: _SkinToneDot(
+ key: const ValueKey('emoji-skin-tone-dot-selected'),
+ color: selected.color,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SkinToneDot extends StatelessWidget {
+ const _SkinToneDot({super.key, required this.color});
+
+ final Color color;
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox.square(
+ dimension: 16,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ DecoratedBox(
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
+ ),
+ ClipOval(
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ Colors.white.withValues(alpha: 0.2),
+ Colors.transparent,
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ ),
+ ),
+ DecoratedBox(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.black.withValues(alpha: 0.8)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
class _CategoryIcon extends StatelessWidget {
final IconData icon;
final String tooltip;
diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
index 354a31e44c6..eb7217be027 100644
--- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
+++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart
@@ -63,7 +63,19 @@ List _sectionOffsets(List<_EmojiSection> sections) {
}
/// Which section owns [offset] — the one whose header is pinned right now.
-int _activeSectionIndex(List offsets, double offset) {
+/// At the clamped bottom, the final visible section owns the viewport even when
+/// it is too short for its header to reach the top.
+int _activeSectionIndex(
+ List offsets,
+ double offset, {
+ required double maxScrollExtent,
+}) {
+ if (offsets.isEmpty) return 0;
+ if (maxScrollExtent > 0 &&
+ offset >= maxScrollExtent - precisionErrorTolerance) {
+ return offsets.length - 1;
+ }
+
var active = 0;
for (var i = 0; i < offsets.length; i++) {
// Half a header of slack so the highlight flips as a header reaches the
@@ -92,7 +104,7 @@ class _EmojiTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
- key: ValueKey('$keyPrefix-${entry.tileId}'),
+ key: ValueKey('$keyPrefix-${entry.id}'),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Semantics(
@@ -196,11 +208,13 @@ class _EmojiSearchResults extends StatelessWidget {
final List entries;
final List customEmoji;
final void Function(String emoji) onSelect;
+ final ScrollController controller;
const _EmojiSearchResults({
required this.entries,
required this.customEmoji,
required this.onSelect,
+ required this.controller,
});
@override
@@ -214,6 +228,7 @@ class _EmojiSearchResults extends StatelessWidget {
return CustomScrollView(
key: const ValueKey('emoji-picker-search-results'),
+ controller: controller,
slivers: [
if (customEmoji.isNotEmpty) ...[
const _SectionHeader(label: 'Custom'),
@@ -279,7 +294,7 @@ class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
bool overlapsContent,
) {
return Container(
- color: context.colors.surfaceContainerHighest,
+ color: context.colors.surface,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: _sectionLabelStyle(context)),
diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
new file mode 100644
index 00000000000..2986af96360
--- /dev/null
+++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
@@ -0,0 +1,191 @@
+part of '../emoji_picker.dart';
+
+const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
+
+/// Guards the process-global native method-call handler: one native sheet may
+/// own it at a time. A reentrant open would replace the handler and hijack the
+/// live sheet's select/dismiss callbacks, so [_presentIosEmojiPicker] coalesces
+/// reentry while a presentation is in flight.
+bool _iosEmojiPickerPresenting = false;
+
+@visibleForTesting
+void resetIosEmojiPickerPresentationForTest() {
+ _iosEmojiPickerPresenting = false;
+}
+
+Future _presentIosEmojiPicker({
+ required BuildContext context,
+ required void Function(String emoji) onSelect,
+ VoidCallback? onDismiss,
+}) async {
+ // Only one native sheet owns the handler at a time. Reject a reentrant open
+ // without replacing the live sheet's callbacks, and complete the rejected
+ // caller so its local picker-open lifecycle is not stranded.
+ if (_iosEmojiPickerPresenting) {
+ onDismiss?.call();
+ return;
+ }
+ _iosEmojiPickerPresenting = true;
+
+ final container = ProviderScope.containerOf(context, listen: false);
+ final paletteState = container.read(customEmojiPaletteProvider);
+ final List customEmoji;
+ BuildContext? loadingSheetContext;
+ var leavingLoadingSheet = false;
+ var loadingCancelled = false;
+ Future? loadingSheet;
+
+ if (paletteState case AsyncData(:final value)) {
+ customEmoji = value;
+ } else {
+ // Palette history can take the relay timeout to resolve. Give the tap an
+ // immediate, cancellable surface instead of holding the global guard while
+ // the composer appears unresponsive.
+ final loadingSheetBuilt = Completer();
+ loadingSheet =
+ showBuzzModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ showCloseButton: false,
+ builder: (sheetContext) {
+ loadingSheetContext = sheetContext;
+ if (!loadingSheetBuilt.isCompleted) loadingSheetBuilt.complete();
+ return const SizedBox(
+ key: Key('ios-emoji-picker-palette-loading'),
+ height: 180,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(),
+ SizedBox(height: Grid.sm),
+ Text('Loading emoji…'),
+ ],
+ ),
+ ),
+ );
+ },
+ ).whenComplete(() {
+ if (leavingLoadingSheet) return;
+ loadingCancelled = true;
+ _iosEmojiPickerPresenting = false;
+ onDismiss?.call();
+ });
+
+ try {
+ customEmoji = await container.read(customEmojiPaletteProvider.future);
+ } catch (_) {
+ if (loadingCancelled) return;
+ await loadingSheetBuilt.future;
+ if (loadingCancelled) return;
+ leavingLoadingSheet = true;
+ if (loadingSheetContext case final sheetContext?
+ when sheetContext.mounted) {
+ Navigator.of(sheetContext).pop();
+ }
+ await loadingSheet;
+ // A palette fetch failure must not strand the composer's open state: fall
+ // back to the Flutter picker, which watches the palette itself.
+ _iosEmojiPickerPresenting = false;
+ if (context.mounted) {
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+ }
+ return;
+ }
+
+ if (loadingCancelled) return;
+ await loadingSheetBuilt.future;
+ if (loadingCancelled) return;
+ leavingLoadingSheet = true;
+ if (loadingSheetContext case final sheetContext?
+ when sheetContext.mounted) {
+ Navigator.of(sheetContext).pop();
+ }
+ await loadingSheet;
+ }
+ if (!context.mounted) {
+ _iosEmojiPickerPresenting = false;
+ return;
+ }
+ final recent = container.read(recentEmojiProvider);
+ final mediaAuth = container.read(mediaGetAuthServiceProvider);
+ final prefs = container.read(savedPrefsProvider);
+ final colors = context.colors;
+ var dismissed = false;
+
+ void finish() {
+ if (dismissed) return;
+ dismissed = true;
+ _iosEmojiPickerPresenting = false;
+ _nativeEmojiPickerChannel.setMethodCallHandler(null);
+ onDismiss?.call();
+ }
+
+ _nativeEmojiPickerChannel.setMethodCallHandler((call) async {
+ switch (call.method) {
+ case 'mediaHeaders':
+ final url = call.arguments;
+ return url is String
+ ? mediaAuth.headersFor(url)
+ : const {};
+ case 'selected':
+ final emoji = call.arguments;
+ if (emoji is String && emoji.isNotEmpty) onSelect(emoji);
+ return null;
+ case 'dismissed':
+ finish();
+ return null;
+ case 'skinToneChanged':
+ final value = call.arguments;
+ if (value is int) {
+ await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value));
+ }
+ return null;
+ }
+ });
+
+ try {
+ final presented = await _nativeEmojiPickerChannel.invokeMethod(
+ 'present',
+ {
+ 'customEmoji': [
+ for (final emoji in customEmoji)
+ {'shortcode': emoji.shortcode, 'url': emoji.url},
+ ],
+ 'recent': [for (final entry in recent) entry.emoji],
+ 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
+ 'surfaceColor': colors.surface.toARGB32(),
+ 'controlColor': colors.surfaceContainerHighest.toARGB32(),
+ 'textColor': colors.onSurface.toARGB32(),
+ 'secondaryTextColor': colors.onSurfaceVariant.toARGB32(),
+ 'accentColor': colors.primary.toARGB32(),
+ 'dividerColor': colors.outlineVariant.toARGB32(),
+ 'isDark': Theme.of(context).brightness == Brightness.dark,
+ },
+ );
+ if (presented == true) return;
+ } on MissingPluginException {
+ // Older builds keep the complete Flutter picker as a safe fallback.
+ } on PlatformException {
+ // A native presentation failure should not remove the emoji affordance.
+ }
+
+ if (dismissed || !context.mounted) {
+ // `dismissed` means finish() already released the guard; the unmounted
+ // path releases it here so a future open is not blocked.
+ _iosEmojiPickerPresenting = false;
+ return;
+ }
+ dismissed = true;
+ _iosEmojiPickerPresenting = false;
+ _nativeEmojiPickerChannel.setMethodCallHandler(null);
+ _showFlutterEmojiPicker(
+ context: context,
+ onSelect: onSelect,
+ onDismiss: onDismiss,
+ );
+}
diff --git a/mobile/lib/features/channels/emoji_picker/search_field.dart b/mobile/lib/features/channels/emoji_picker/search_field.dart
index dd4c4ee9233..c61dd9d2cc8 100644
--- a/mobile/lib/features/channels/emoji_picker/search_field.dart
+++ b/mobile/lib/features/channels/emoji_picker/search_field.dart
@@ -8,68 +8,80 @@ part of '../emoji_picker.dart';
/// words and the OS mangles them. Flutter exposes the same switches directly.
class _EmojiSearchField extends StatelessWidget {
final TextEditingController controller;
+ final EdgeInsetsGeometry padding;
- const _EmojiSearchField({required this.controller});
+ const _EmojiSearchField({
+ required this.controller,
+ this.padding = const EdgeInsets.fromLTRB(
+ Grid.gutter,
+ 0,
+ Grid.gutter,
+ Grid.xxs,
+ ),
+ });
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Padding(
- padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs),
- child: TextField(
- key: const ValueKey('emoji-picker-search'),
- controller: controller,
- autocorrect: false,
- enableSuggestions: false,
- textCapitalization: TextCapitalization.none,
- textInputAction: TextInputAction.search,
- style: searchInputTextStyle.copyWith(color: colors.onSurface),
- decoration: InputDecoration(
- hintText: 'Search emoji',
- hintStyle: searchInputTextStyle.copyWith(
- color: colors.onSurfaceVariant,
- ),
- prefixIcon: Icon(
- LucideIcons.search,
- size: 18,
- color: colors.onSurfaceVariant,
- ),
- prefixIconConstraints: const BoxConstraints(
- minWidth: Grid.md,
- minHeight: Grid.md,
- ),
- suffixIcon: ValueListenableBuilder(
- valueListenable: controller,
- builder: (context, value, _) {
- if (value.text.isEmpty) return const SizedBox.shrink();
- return IconButton(
- key: const ValueKey('emoji-picker-search-clear'),
- onPressed: controller.clear,
- icon: Icon(
- LucideIcons.x,
- size: 16,
- color: colors.onSurfaceVariant,
- ),
- visualDensity: VisualDensity.compact,
- tooltip: 'Clear search',
- );
- },
- ),
- filled: true,
- fillColor: colors.surface,
- isDense: true,
- contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.outlineVariant),
- ),
- enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.outlineVariant),
- ),
- focusedBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(Radii.lg),
- borderSide: BorderSide(color: colors.primary),
+ padding: padding,
+ child: SizedBox(
+ height: 44,
+ child: TextField(
+ key: const ValueKey('emoji-picker-search'),
+ controller: controller,
+ autocorrect: false,
+ enableSuggestions: false,
+ textCapitalization: TextCapitalization.none,
+ textInputAction: TextInputAction.search,
+ style: searchInputTextStyle.copyWith(color: colors.onSurface),
+ decoration: InputDecoration(
+ hintText: 'Search emoji',
+ hintStyle: searchInputTextStyle.copyWith(
+ color: colors.onSurfaceVariant,
+ ),
+ prefixIcon: Icon(
+ LucideIcons.search,
+ size: 18,
+ color: colors.onSurfaceVariant,
+ ),
+ prefixIconConstraints: const BoxConstraints(
+ minWidth: Grid.md,
+ minHeight: Grid.md,
+ ),
+ suffixIcon: ValueListenableBuilder(
+ valueListenable: controller,
+ builder: (context, value, _) {
+ if (value.text.isEmpty) return const SizedBox.shrink();
+ return IconButton(
+ key: const ValueKey('emoji-picker-search-clear'),
+ onPressed: controller.clear,
+ icon: Icon(
+ LucideIcons.x,
+ size: 16,
+ color: colors.onSurfaceVariant,
+ ),
+ visualDensity: VisualDensity.compact,
+ tooltip: 'Clear search',
+ );
+ },
+ ),
+ filled: true,
+ fillColor: colors.surface,
+ isDense: true,
+ contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.outlineVariant),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(Radii.full),
+ borderSide: BorderSide(color: colors.primary),
+ ),
),
),
),
diff --git a/mobile/lib/features/channels/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/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/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/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart
index 6ab3ddd9b63..29886331580 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -43,6 +45,12 @@ part 'thread_detail_page/nested_thread_summary_row.dart';
part 'thread_detail_helpers.dart';
part 'thread_detail_page/tail_alignment.dart';
part 'thread_detail_page/thread_message.dart';
+part 'thread_detail_page/avatar.dart';
+
+const _landingHighlightDuration = Duration(seconds: 3);
+const _landingHighlightDelay = Duration(milliseconds: 50);
+const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
+const _landingHighlightOpacity = 0.12;
/// Full-screen thread detail page.
///
@@ -72,21 +80,28 @@ class ThreadDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final appView = View.of(context);
final composerDockHeight = useState(0.0);
+ final composerFocusNode = useFocusNode();
+ final restoreComposerFocus = useRef(null);
final settledImeBottomInset = useState(
usesFixedAndroidImeViewport
? appView.viewInsets.bottom / appView.devicePixelRatio
: 0.0,
);
+ useEffect(() {
+ final session = ref.read(relaySessionProvider.notifier);
+ return session.registerVisibleChannel(channelId);
+ }, [channelId]);
final sendMessage = ref.read(sendMessageProvider);
// Relay thread queries are keyed by the outermost root, even when this
// page displays a nested branch. Query that root, then select this head's
// direct children from the returned subtree below.
final queryRootId = threadHead.rootId ?? threadHead.id;
- final repliesState = ref.watch(
- threadRepliesWithLocalProvider(
- ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
- ),
+ final repliesArgs = ThreadRepliesArgs(
+ channelId: channelId,
+ rootId: queryRootId,
);
+ final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs));
+ final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs));
// The thread query is one-shot and asks only for content kinds, so a
// reaction, edit, or deletion that lands while the thread is open never
// reaches it — a new pill (and its burst) only showed up after leaving and
@@ -103,6 +118,12 @@ class ThreadDetailPage extends HookConsumerWidget {
});
final fetchedReplies = replyMessages.value;
+ // A terminal query error cannot produce a more authoritative list. Keep
+ // loading states provisional, but let the hydrated route snapshot drive
+ // the one-shot target jump when the relay query has definitively failed.
+ final canUseMessagesForInitialTarget =
+ relayReplyState.value != null ||
+ (relayReplyState.hasError && !relayReplyState.retrying);
final liveDeletionHidesHead = _isDeletedBy(
liveChannelEvents,
threadHead.id,
@@ -119,6 +140,68 @@ class ThreadDetailPage extends HookConsumerWidget {
threadHead,
...fetchedReplies,
];
+ final routeAnimation = ModalRoute.of(context)?.animation;
+ final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf(
+ context,
+ );
+ final highlightedMessageId = useState(null);
+ final initialTargetReadyForHighlight = useState(false);
+ useEffect(
+ () {
+ final messageId = initialMessageId;
+ if (messageId == null || !initialTargetReadyForHighlight.value) {
+ return null;
+ }
+ var disposed = false;
+ Timer? revealTimer;
+ Timer? dismissTimer;
+
+ void revealHighlight() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (disposed) return;
+ revealTimer = Timer(_landingHighlightDelay, () {
+ if (disposed) return;
+ highlightedMessageId.value = messageId;
+ dismissTimer = Timer(
+ _landingHighlightDuration +
+ (reducedLandingHighlightMotion
+ ? Duration.zero
+ : _landingHighlightTransitionDuration),
+ () {
+ if (!disposed) highlightedMessageId.value = null;
+ },
+ );
+ });
+ });
+ }
+
+ void handleRouteStatus(AnimationStatus status) {
+ if (status != AnimationStatus.completed) return;
+ routeAnimation?.removeStatusListener(handleRouteStatus);
+ revealHighlight();
+ }
+
+ if (routeAnimation == null ||
+ routeAnimation.status == AnimationStatus.completed) {
+ revealHighlight();
+ } else {
+ routeAnimation.addStatusListener(handleRouteStatus);
+ }
+
+ return () {
+ disposed = true;
+ routeAnimation?.removeStatusListener(handleRouteStatus);
+ revealTimer?.cancel();
+ dismissTimer?.cancel();
+ };
+ },
+ [
+ initialMessageId,
+ initialTargetReadyForHighlight.value,
+ reducedLandingHighlightMotion,
+ routeAnimation,
+ ],
+ );
// Index all messages by parentId so we can find direct children of any
// message and compute thread summaries for nested threads.
@@ -135,6 +218,7 @@ class ThreadDetailPage extends HookConsumerWidget {
final listViewport = useMemoized(LaidOutViewport.new);
useEffect(() => listViewport.dispose, [listViewport]);
final didJumpToInitialMessage = useRef(false);
+ final initialHighlightTargetIndex = useState(null);
final followsThreadTail = useRef(false);
final userOptedOutOfTailFollow = useRef(false);
final userDragDetachedTailFollow = useRef(false);
@@ -268,42 +352,84 @@ class ThreadDetailPage extends HookConsumerWidget {
);
}
- useEffect(() {
- final messageId = initialMessageId;
- // Wait for the authoritative thread query before consuming the one-shot
- // jump; the fallback main-timeline list can contain only the linked reply.
- if (messageId == null || fetchedReplies == null) return null;
- final chronologicalIndex = replies.indexWhere(
- (reply) => reply.id == messageId,
- );
- final targetIndex = messageId == threadHead.id
- ? headIndex
- : chronologicalIndex < 0
- ? null
- : indexForReply(chronologicalIndex);
- if (targetIndex == null || didJumpToInitialMessage.value) return null;
- didJumpToInitialMessage.value = true;
- initialTailSettle.abandon();
- userOptedOutOfTailFollow.value = true;
- userDragDetachedTailFollow.value = false;
- tailIntent.schedule(
- allowed: true,
- revalidate: () =>
- context.mounted &&
- itemScrollController.isAttached &&
- !tailIntent.isDragging,
- action: () {
- // The provisional route snapshot can make the linked reply look like
- // the tail. This authoritative deep-link jump intentionally leaves
- // the user at an older item, so it must opt out of follow-tail first.
- tailIntent.detach();
- followsThreadTail.value = false;
- isAtThreadTail.value = false;
- itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
- },
- );
- return null;
- }, [initialMessageId, fetchedReplies, replies.length]);
+ useEffect(
+ () {
+ final messageId = initialMessageId;
+ // Wait for either the authoritative thread query or a terminal query
+ // error before consuming the one-shot jump. During loading, the fallback
+ // main-timeline list can contain only the linked reply; after an error,
+ // that hydrated snapshot is the best available target list.
+ if (messageId == null || !canUseMessagesForInitialTarget) return null;
+ final chronologicalIndex = replies.indexWhere(
+ (reply) => reply.id == messageId,
+ );
+ final targetIndex = messageId == threadHead.id
+ ? headIndex
+ : chronologicalIndex < 0
+ ? null
+ : indexForReply(chronologicalIndex);
+ if (targetIndex == null || didJumpToInitialMessage.value) return null;
+ didJumpToInitialMessage.value = true;
+ initialTailSettle.abandon();
+ userOptedOutOfTailFollow.value = true;
+ userDragDetachedTailFollow.value = false;
+ tailIntent.schedule(
+ allowed: true,
+ revalidate: () =>
+ context.mounted &&
+ itemScrollController.isAttached &&
+ !tailIntent.isDragging,
+ action: () {
+ // The provisional route snapshot can make the linked reply look like
+ // the tail. This authoritative deep-link jump intentionally leaves
+ // the user at an older item, so it must opt out of follow-tail first.
+ tailIntent.detach();
+ followsThreadTail.value = false;
+ isAtThreadTail.value = false;
+ itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
+ initialHighlightTargetIndex.value = targetIndex;
+ },
+ );
+ return null;
+ },
+ [
+ initialMessageId,
+ canUseMessagesForInitialTarget,
+ fetchedReplies,
+ replies.length,
+ ],
+ );
+
+ useEffect(
+ () {
+ final targetIndex = initialHighlightTargetIndex.value;
+ if (targetIndex == null || initialTargetReadyForHighlight.value) {
+ return null;
+ }
+ var completionScheduled = false;
+ void markReadyAfterTargetLayout() {
+ if (completionScheduled ||
+ !itemPositionsListener.itemPositions.value.any(
+ (position) => position.index == targetIndex,
+ )) {
+ return;
+ }
+ completionScheduled = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted) initialTargetReadyForHighlight.value = true;
+ });
+ }
+
+ itemPositionsListener.itemPositions.addListener(
+ markReadyAfterTargetLayout,
+ );
+ markReadyAfterTargetLayout();
+ return () => itemPositionsListener.itemPositions.removeListener(
+ markReadyAfterTargetLayout,
+ );
+ },
+ [initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value],
+ );
// A top-anchored list doesn't stick to the newest item the way the old
// reversed one did, so follow the tail explicitly: when a reply arrives
@@ -630,11 +756,14 @@ class ThreadDetailPage extends HookConsumerWidget {
currentPubkey: currentPubkey,
showAuthor: true,
isHighlighted:
- liveHead.id == initialMessageId,
+ liveHead.id == highlightedMessageId.value,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
isThreadHead: true,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
Padding(
padding: const EdgeInsets.symmetric(
@@ -710,10 +839,14 @@ class ThreadDetailPage extends HookConsumerWidget {
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: showAuthor,
- isHighlighted: reply.id == initialMessageId,
+ isHighlighted:
+ reply.id == highlightedMessageId.value,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: () =>
+ restoreComposerFocus.value?.call(),
),
if (nestedSummary != null)
_NestedThreadSummaryRow(
@@ -750,6 +883,9 @@ class ThreadDetailPage extends HookConsumerWidget {
_ThreadTypingIndicator(entries: threadTyping),
ComposeBar(
channelId: channelId,
+ focusNode: composerFocusNode,
+ onFocusRestorerChanged: (restoreFocus) =>
+ restoreComposerFocus.value = restoreFocus,
hintText: 'Reply in thread\u2026',
threadHeadId: threadHead.id,
rootId: effectiveRootId,
diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart
new file mode 100644
index 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..89bdd40998f 100644
--- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart
+++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart
@@ -1,6 +1,6 @@
part of '../thread_detail_page.dart';
-class _ThreadMessage extends ConsumerWidget {
+class _ThreadMessage extends HookConsumerWidget {
final TimelineMessage message;
final Map channelNames;
final String channelId;
@@ -10,6 +10,8 @@ class _ThreadMessage extends ConsumerWidget {
final List? allMessages;
final bool isMember;
final bool isArchived;
+ final FocusNode? composerFocusNode;
+ final VoidCallback? restoreComposerFocus;
/// Whether this is the message the thread hangs off, which keeps a standing
/// "+" where replies only get one once they carry a reaction.
@@ -26,10 +28,13 @@ class _ThreadMessage extends ConsumerWidget {
this.isMember = false,
this.isArchived = false,
this.isThreadHead = false,
+ this.composerFocusNode,
+ this.restoreComposerFocus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
@@ -67,7 +72,7 @@ class _ThreadMessage extends ConsumerWidget {
agentMentionPubkeys: agentMentionPubkeys,
);
- void openMessageActions(Rect anchorRect) {
+ void openMessageActions(MessageLongPressDetails details) {
showMessageActions(
context: context,
ref: ref,
@@ -78,18 +83,46 @@ class _ThreadMessage extends ConsumerWidget {
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
- anchorRect: anchorRect,
+ anchorRect: details.anchorRect,
+ captureAnchorSnapshot: details.captureSnapshot,
+ onPopoverPreviewVisibilityChanged: details.setSourceHidden,
+ onPopoverDismissed: () => details.setSourceHidden(false),
+ composerFocusNode: composerFocusNode,
+ restoreComposerFocus: restoreComposerFocus,
);
}
+ final reducedMotion = MediaQuery.disableAnimationsOf(context);
+ final highlightController = useAnimationController(
+ duration: _landingHighlightTransitionDuration,
+ );
+ final highlightProgress = useAnimation(highlightController);
+ useEffect(() {
+ if (reducedMotion) {
+ highlightController.value = isHighlighted ? 1 : 0;
+ } else {
+ unawaited(
+ highlightController.animateTo(
+ isHighlighted ? 1 : 0,
+ duration: _landingHighlightTransitionDuration,
+ curve: Curves.easeOutCubic,
+ ),
+ );
+ }
+ return null;
+ }, [highlightController, isHighlighted, reducedMotion]);
+ final highlightColor = highlightProgress == 0
+ ? Colors.transparent
+ : context.colors.primary.withValues(
+ alpha: _landingHighlightOpacity * highlightProgress,
+ );
+
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
- color: isHighlighted
- ? context.colors.primary.withValues(alpha: 0.12)
- : Colors.transparent,
+ color: highlightColor,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Material(
@@ -101,151 +134,174 @@ class _ThreadMessage extends ConsumerWidget {
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
- onLongPress: openMessageActions,
+ onLongPressDetails: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
+ snapshotKey: messageSnapshotKey,
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- if (showAuthor)
- GestureDetector(
- onTap: () =>
- showUserProfileSheet(context, message.pubkey),
- child: _Avatar(profile: profile, pubkey: message.pubkey),
- )
- else
- const SizedBox(width: messageAvatarSize),
- const SizedBox(width: messageAvatarContentGap),
- Expanded(
- child: Padding(
- padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (showAuthor)
- Padding(
- padding: const EdgeInsets.only(
- bottom: Grid.quarter,
- ),
- child: Row(
- children: [
- Expanded(
- child: MessageAuthorMeta(
- displayName: displayName,
- username: messageUsernameLabel(profile),
- timestamp: formatMessageTime(
- message.createdAt,
- ),
- nameColor: context.colors.onSurface,
- metadataColor:
- context.colors.onSurfaceVariant,
- onAuthorTap: () => showUserProfileSheet(
- context,
- message.pubkey,
- ),
- displayNameKey: ValueKey(
- 'thread-message-author-${message.id}',
- ),
- usernameKey: ValueKey(
- 'thread-message-username-${message.id}',
- ),
- timestampKey: ValueKey(
- 'thread-message-timestamp-${message.id}',
- ),
+ RepaintBoundary(
+ key: messageSnapshotKey,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ GestureDetector(
+ onTap: () =>
+ showUserProfileSheet(context, message.pubkey),
+ child: _Avatar(
+ profile: profile,
+ pubkey: message.pubkey,
+ ),
+ )
+ else
+ const SizedBox(width: messageAvatarSize),
+ const SizedBox(width: messageAvatarContentGap),
+ Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ top: showAuthor ? Grid.half : 0,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (showAuthor)
+ Padding(
+ padding: const EdgeInsets.only(
+ bottom: Grid.quarter,
),
- ),
- 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 +311,3 @@ class _ThreadMessage extends ConsumerWidget {
);
}
}
-
-class _Avatar extends StatelessWidget {
- final UserProfile? profile;
- final String pubkey;
-
- const _Avatar({required this.profile, required this.pubkey});
-
- @override
- Widget build(BuildContext context) {
- final initial =
- profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
- final avatarUrl = profile?.avatarUrl;
-
- return AvatarImage(
- imageUrl: avatarUrl,
- radius: messageAvatarSize / 2,
- backgroundColor: context.colors.primaryContainer,
- fallback: Text(
- initial,
- style: context.textTheme.labelMedium?.copyWith(
- color: context.colors.onPrimaryContainer,
- fontWeight: FontWeight.w600,
- ),
- ),
- );
- }
-}
diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index 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/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart
index 4a9802f0dae..f5c71adc17e 100644
--- a/mobile/lib/shared/widgets/bee_refresh_indicator.dart
+++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart
@@ -385,10 +385,9 @@ class BeeRefreshIndicator extends HookConsumerWidget {
width: _beeWidth,
color: context.colors.primary,
flapAmount: flapAmount,
- eyeProgress:
- !isLoading &&
- !showEyeEmoji &&
- pupilProgress > 0
+ eyeProgress: showEyeEmoji
+ ? 1
+ : !isLoading && pupilProgress > 0
? pupilProgress
: null,
),
diff --git a/mobile/lib/shared/widgets/flapping_bee.dart b/mobile/lib/shared/widgets/flapping_bee.dart
index 9a99bd87966..a9057b60f2a 100644
--- a/mobile/lib/shared/widgets/flapping_bee.dart
+++ b/mobile/lib/shared/widgets/flapping_bee.dart
@@ -137,7 +137,9 @@ class _FlappingBeePainter extends CustomPainter {
canvas.drawPath(finishedMark, Paint()..color = color);
if (eyeProgress case final progress?) {
- final pupilRadius = 20 * progress.clamp(0.0, 1.0);
+ // The eye cutouts are 54px wide. A full pupil must reach their 27px
+ // radius so the emoji-eye overlay never exposes the background beneath.
+ final pupilRadius = 27 * progress.clamp(0.0, 1.0);
final pupilPaint = Paint()..color = color;
canvas
..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint)
diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart
index 94d040526d7..ad878b309eb 100644
--- a/mobile/test/features/activity/activity_page_test.dart
+++ b/mobile/test/features/activity/activity_page_test.dart
@@ -684,6 +684,10 @@ void main() {
expect(page.channel.id, 'ch1');
expect(page.initialThreadRootId, 'parent-reply');
expect(page.initialMessageId, 'reply-event');
+ expect(
+ page.initialThreadRouteBehavior,
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ );
});
testWidgets('thread filter matches grouped thread replies', (tester) async {
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 79e385452bb..f5d54c257af 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -187,12 +187,20 @@ Widget _buildTestable({
String? canvasContent,
String? initialMessageId,
String? initialThreadRootId,
+ InitialThreadRouteBehavior initialThreadRouteBehavior =
+ InitialThreadRouteBehavior.push,
Map> threadReplies = const {},
Map>> pendingThreadReplies = const {},
+ Map> Function()> threadReplyLoaders =
+ const {},
+ Map> localThreadReplies = const {},
TextScaler textScaler = TextScaler.noScaling,
bool disableAnimations = false,
+ bool disableRetries = false,
+ Duration? Function(int retryCount, Object error)? providerRetry,
RelaySessionNotifier? relaySessionNotifier,
http.Client? mediaClient,
+ Widget? home,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -200,6 +208,7 @@ Widget _buildTestable({
final fakeMessagesNotifier =
messagesNotifier ?? _FakeMessagesNotifier(messages);
return ProviderScope(
+ retry: providerRetry ?? (disableRetries ? (_, _) => null : null),
overrides: [
channelMessagesProvider(
_channelId,
@@ -240,6 +249,19 @@ Widget _buildTestable({
threadRepliesProvider(
ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
).overrideWith((ref) => entry.value),
+ for (final entry in threadReplyLoaders.entries)
+ threadRepliesProvider(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ ).overrideWith((ref) => entry.value()),
+ for (final entry in localThreadReplies.entries)
+ threadLocalRepliesProvider(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ ).overrideWith(
+ () => _FakeThreadLocalRepliesNotifier(
+ ThreadRepliesArgs(channelId: _channelId, rootId: entry.key),
+ entry.value,
+ ),
+ ),
// Stub the relay client provider so preloadMembers doesn't crash.
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
@@ -265,11 +287,14 @@ Widget _buildTestable({
child: child!,
),
navigatorObservers: navigatorObservers,
- home: ChannelDetailPage(
- channel: resolvedChannel,
- initialMessageId: initialMessageId,
- initialThreadRootId: initialThreadRootId,
- ),
+ home:
+ home ??
+ ChannelDetailPage(
+ channel: resolvedChannel,
+ initialMessageId: initialMessageId,
+ initialThreadRootId: initialThreadRootId,
+ initialThreadRouteBehavior: initialThreadRouteBehavior,
+ ),
),
);
}
@@ -1478,8 +1503,19 @@ void main() {
);
await tester.pumpAndSettle();
- expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
- expect(find.byType(BottomSheet), findsOneWidget);
+ expect(
+ find.byKey(const ValueKey('message-action-reaction-tray')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-preview')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const ValueKey('message-action-surface')),
+ findsOneWidget,
+ );
+ expect(find.byType(BottomSheet), findsNothing);
expect(find.text('Copy text'), findsOneWidget);
});
@@ -1717,8 +1753,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 +2552,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 +2650,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 +2867,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 +2983,7 @@ void main() {
expect(findRichText('Newest message line 0'), findsOneWidget);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
- findsOneWidget,
+ findsNothing,
);
messagesNotifier.setMessages([
@@ -4266,6 +4459,372 @@ void main() {
});
group('Deep-link navigation', () {
+ testWidgets('fades the target highlight in after the thread route lands', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Target reply',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final timelineMessages = formatTimeline([root, target]);
+ final threadHead = timelineMessages.firstWhere(
+ (message) => message.id == root.id,
+ );
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ threadReplies: {
+ 'root': [target],
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ ),
+ child: const Text('Open highlighted thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open highlighted thread'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 1));
+
+ final threadRoute =
+ ModalRoute.of(tester.element(find.byType(ThreadDetailPage)))!
+ as MaterialPageRoute;
+ expect(threadRoute.animation!.status, AnimationStatus.forward);
+ final transitionDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(transitionDecoration.color, Colors.transparent);
+
+ await tester.pump(threadRoute.transitionDuration);
+ expect(threadRoute.animation!.status, AnimationStatus.completed);
+ await tester.pump();
+ await tester.pump();
+ final landedDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final enteringDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(enteringDecoration.color!.a, greaterThan(0));
+ expect(enteringDecoration.color!.a, lessThan(0.12));
+
+ await tester.pump(const Duration(milliseconds: 150));
+ final visibleDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(visibleDecoration.color!.a, closeTo(0.12, 0.001));
+ });
+
+ testWidgets('waits for a delayed target jump before highlighting', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final replies = [
+ for (var i = 0; i < 40; i++)
+ _textMsg(
+ id: 'reply-$i',
+ pubkey: 'bob',
+ content: 'Reply $i',
+ createdAt: 1100 + i,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ ),
+ ];
+ final timelineMessages = formatTimeline([root, ...replies]);
+ final threadHead = timelineMessages.first;
+ final replyCompleter = Completer>();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root],
+ pendingThreadReplies: {'root': replyCompleter.future},
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'reply-30',
+ ),
+ ),
+ ),
+ child: const Text('Open delayed thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open delayed thread'));
+ await tester.pumpAndSettle();
+ await tester.pump(const Duration(seconds: 4));
+
+ expect(
+ find.byKey(const ValueKey('thread-message-group-reply-30')),
+ findsNothing,
+ );
+
+ replyCompleter.complete(replies);
+ await tester.pumpAndSettle();
+
+ final target = find.byKey(const ValueKey('thread-message-reply-30'));
+ expect(target, findsOneWidget);
+ final landedDecoration =
+ tester.widget(target).decoration as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final enteringDecoration =
+ tester.widget(target).decoration as BoxDecoration;
+ expect(enteringDecoration.color!.a, greaterThan(0));
+ expect(enteringDecoration.color!.a, lessThan(0.12));
+ });
+
+ testWidgets('waits for a retry before jumping to a hydrated target', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Hydrated target',
+ createdAt: 1400,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final earlierReplies = [
+ for (var i = 0; i < 30; i++)
+ _textMsg(
+ id: 'reply-$i',
+ pubkey: 'bob',
+ content: 'Reply $i',
+ createdAt: 1100 + i,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ ),
+ ];
+ final timelineMessages = formatTimeline([root, target]);
+ final firstAttempt = Completer>();
+ var attempts = 0;
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ providerRetry: (retryCount, _) =>
+ retryCount == 0 ? const Duration(seconds: 30) : null,
+ localThreadReplies: {
+ 'root': [target],
+ },
+ threadReplyLoaders: {
+ 'root': () {
+ attempts++;
+ if (attempts == 1) return firstAttempt.future;
+ return Future.value([...earlierReplies, target]);
+ },
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: ThreadDetailPage(
+ threadHead: timelineMessages.first,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ );
+ await tester.pump();
+ firstAttempt.completeError(Exception('transient thread query failure'));
+ await tester.pump();
+ await tester.pump();
+
+ final targetFinder = find.byKey(const ValueKey('thread-message-target'));
+ expect(targetFinder, findsOneWidget);
+ final retryingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(retryingDecoration.color, Colors.transparent);
+ expect(attempts, 1);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final stillRetryingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(stillRetryingDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 2800));
+ expect(attempts, 1);
+ final expiredJumpDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(expiredJumpDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(seconds: 30));
+ await tester.pumpAndSettle();
+
+ expect(attempts, 2);
+ expect(
+ find.byKey(const ValueKey('thread-message-group-target')),
+ findsOneWidget,
+ );
+ final landedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(landedDecoration.color, Colors.transparent);
+
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+ final highlightedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(highlightedDecoration.color!.a, greaterThan(0));
+ });
+
+ testWidgets('highlights a hydrated target after the thread query fails', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Hydrated target',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final timelineMessages = formatTimeline([root, target]);
+ final replyCompleter = Completer>();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ pendingThreadReplies: {'root': replyCompleter.future},
+ disableRetries: true,
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: ThreadDetailPage(
+ threadHead: timelineMessages.first,
+ allMessages: timelineMessages,
+ channelId: _testChannel.id,
+ currentPubkey: null,
+ isMember: true,
+ isArchived: false,
+ initialMessageId: 'target',
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final targetFinder = find.byKey(const ValueKey('thread-message-target'));
+ expect(targetFinder, findsOneWidget);
+ final loadingDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(loadingDecoration.color, Colors.transparent);
+
+ replyCompleter.completeError(Exception('thread query failed'));
+ for (var i = 0; i < 8; i++) {
+ await tester.pump();
+ }
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.pump(const Duration(milliseconds: 150));
+
+ final highlightedDecoration =
+ tester.widget(targetFinder).decoration as BoxDecoration;
+ expect(highlightedDecoration.color!.a, greaterThan(0));
+ expect(highlightedDecoration.color!.a, lessThan(0.12));
+ });
+
testWidgets('opens a nested reply in its direct-parent thread', (
tester,
) async {
@@ -4324,7 +4883,147 @@ void main() {
find.byKey(const ValueKey('thread-message-target')),
);
final decoration = highlighted.decoration as BoxDecoration;
- expect(decoration.color, isNot(Colors.transparent));
+ final initialHighlight = decoration.color!;
+ expect(initialHighlight, isNot(Colors.transparent));
+ expect(initialHighlight.a, closeTo(0.12, 0.001));
+
+ await tester.pump(const Duration(milliseconds: 2999));
+ final heldDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(heldDecoration.color, initialHighlight);
+
+ await tester.pump(const Duration(milliseconds: 1));
+ await tester.pump(const Duration(milliseconds: 150));
+
+ final fadingDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(fadingDecoration.color!.a, greaterThan(0));
+ expect(fadingDecoration.color!.a, lessThan(initialHighlight.a));
+
+ await tester.pump(const Duration(milliseconds: 150));
+ final dismissedDecoration =
+ tester
+ .widget(
+ find.byKey(const ValueKey('thread-message-target')),
+ )
+ .decoration
+ as BoxDecoration;
+ expect(dismissedDecoration.color, Colors.transparent);
+ });
+
+ testWidgets('does not replace a newer route after delayed hydration', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ );
+ final messagesNotifier = _FakeMessagesNotifier(const []);
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: const [],
+ messagesNotifier: messagesNotifier,
+ initialThreadRootId: 'root',
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final navigator = Navigator.of(
+ tester.element(find.byType(ChannelDetailPage)),
+ );
+ messagesNotifier.setMessages([root]);
+ navigator.push(
+ MaterialPageRoute(
+ builder: (_) => const Scaffold(body: Text('New destination')),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('New destination'), findsOneWidget);
+ expect(find.byType(ThreadDetailPage), findsNothing);
+ });
+
+ testWidgets('replaces a temporary channel route for an initial thread', (
+ tester,
+ ) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ );
+ final target = _textMsg(
+ id: 'target',
+ pubkey: 'bob',
+ content: 'Target reply',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final relaySession = _TrackingRelaySession();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, target],
+ relaySessionNotifier: relaySession,
+ threadReplies: {
+ 'root': [target],
+ },
+ users: const {
+ 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
+ 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
+ },
+ home: Builder(
+ builder: (context) => Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ChannelDetailPage(
+ channel: _testChannel,
+ initialMessageId: 'target',
+ initialThreadRootId: 'root',
+ initialThreadRouteBehavior:
+ InitialThreadRouteBehavior.replaceCurrentRoute,
+ ),
+ ),
+ ),
+ child: const Text('Open activity thread'),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Open activity thread'));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ThreadDetailPage), findsOneWidget);
+ expect(find.byType(ChannelDetailPage), findsNothing);
+ expect(relaySession.visibleChannels, [_testChannel.id]);
+
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+
+ expect(find.text('Open activity thread'), findsOneWidget);
+ expect(find.byType(ChannelDetailPage), findsNothing);
+ expect(relaySession.visibleChannels, isEmpty);
});
});
@@ -6057,7 +6756,9 @@ void main() {
const ValueKey('thread-message-group-reply-29'),
);
final composer = find.byKey(const ValueKey('composer-surface'));
- await tester.drag(list, const Offset(0, 24));
+ // Clear the gesture arena's touch slop so this represents a deliberate
+ // tail-detaching drag rather than a long-press hold with small motion.
+ await tester.drag(list, const Offset(0, 48));
await tester.pumpAndSettle();
expect(
tester.getBottomLeft(latest).dy,
@@ -7122,6 +7823,15 @@ Channel _channel({required String id, required String name}) => Channel(
isMember: true,
);
+class _FakeThreadLocalRepliesNotifier extends ThreadLocalRepliesNotifier {
+ final List _replies;
+
+ _FakeThreadLocalRepliesNotifier(super.args, this._replies);
+
+ @override
+ List build() => _replies;
+}
+
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
List _messages;
bool _hasLoadedMessages;
@@ -7173,6 +7883,27 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier {
AsyncError('Connection failed', StackTrace.current);
}
+class _TrackingRelaySession extends RelaySessionNotifier {
+ final visibleChannels = [];
+
+ @override
+ SessionState build() =>
+ const SessionState(status: SessionStatus.disconnected);
+
+ @override
+ void Function() registerVisibleChannel(String channelId) {
+ final release = super.registerVisibleChannel(channelId);
+ visibleChannels.add(channelId);
+ var released = false;
+ return () {
+ if (released) return;
+ released = true;
+ visibleChannels.remove(channelId);
+ release();
+ };
+ }
+}
+
class _ReconnectingRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index 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/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/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart
index bf8086da4c7..9c1cf556169 100644
--- a/mobile/test/features/channels/emoji_picker_test.dart
+++ b/mobile/test/features/channels/emoji_picker_test.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:buzz/features/channels/emoji_picker.dart';
import 'package:buzz/features/channels/recent_emoji_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
@@ -6,9 +8,13 @@ import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:nostr/nostr.dart' as nostr;
import '../../helpers/widget_helpers.dart';
@@ -41,11 +47,39 @@ final _dataset = () {
),
_entry(
'point_up',
- native: '\u{261D}\u{1F3FD}',
+ native: '\u{261D}\u{1F3FB}',
categoryId: 'people',
name: 'Index Pointing Up',
skinIndex: 1,
),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FC}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 2,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FD}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 3,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FE}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 4,
+ ),
+ _entry(
+ 'point_up',
+ native: '\u{261D}\u{1F3FF}',
+ categoryId: 'people',
+ name: 'Index Pointing Up',
+ skinIndex: 5,
+ ),
];
final nature = [
_entry(
@@ -95,6 +129,49 @@ final _tallDataset = () {
const _customEmoji = [
CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'),
];
+const _relayCustomEmoji = [
+ CustomEmoji(
+ shortcode: 'buzzbee',
+ url: 'https://relay.example/media/buzzbee.png',
+ ),
+];
+
+class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier {
+ _FakeCustomEmojiPaletteNotifier(this.palette);
+
+ final Future> palette;
+
+ @override
+ Future> build() => palette;
+}
+
+const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
+
+void _setMockNativeEmojiPickerHandler(
+ Future Function(MethodCall call)? handler,
+) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler);
+}
+
+Future _sendNativeEmojiPickerCall(
+ WidgetTester tester,
+ String method, [
+ Object? arguments,
+]) async {
+ final response = Completer();
+ await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
+ _nativeEmojiPickerChannel.name,
+ _nativeEmojiPickerChannel.codec.encodeMethodCall(
+ MethodCall(method, arguments),
+ ),
+ response.complete,
+ );
+ final envelope = await response.future;
+ return envelope == null
+ ? null
+ : _nativeEmojiPickerChannel.codec.decodeEnvelope(envelope);
+}
Future _prefs() {
SharedPreferences.setMockInitialValues({});
@@ -147,26 +224,113 @@ void main() {
// shortcut into it, not a page switcher.
final grid = find.byKey(const ValueKey('emoji-picker-grid'));
expect(grid, findsOneWidget);
+ final sectionKeys = tester
+ .widget(grid)
+ .slivers
+ .whereType()
+ .map((sliver) => sliver.key);
expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-grinning')),
- ),
- findsOneWidget,
+ sectionKeys,
+ containsAllInOrder(const [
+ ValueKey('emoji-section-people'),
+ ValueKey('emoji-section-nature'),
+ ValueKey('emoji-section-custom'),
+ ]),
);
+ });
+
+ testWidgets(
+ 'search shares the sheet header with the shared close control',
+ (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final search = tester.getRect(
+ find.byKey(const ValueKey('emoji-picker-search')),
+ );
+ final close = tester.getRect(find.byTooltip('Close sheet'));
+
+ expect(close.size, const Size.square(44));
+ expect(search.center.dy, close.center.dy);
+ expect(close.left - search.right, Grid.xxs);
+ },
+ );
+
+ testWidgets('the Flutter picker keeps the established tray height', (
+ tester,
+ ) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final picker = find.byType(EmojiPickerSheet);
+ final context = tester.element(picker);
expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-fire')),
+ tester.getSize(picker).height,
+ closeTo(MediaQuery.sizeOf(context).height * 0.62, 0.5),
+ );
+ expect(find.byType(DraggableScrollableSheet), findsNothing);
+ });
+
+ testWidgets('the Flutter search field is a full pill', (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs());
+
+ final field = tester.widget(
+ find.byKey(const ValueKey('emoji-picker-search')),
+ );
+ final border = field.decoration!.border! as OutlineInputBorder;
+ expect(border.borderRadius, BorderRadius.circular(Radii.full));
+ });
+
+ testWidgets('uses the shared sheet surface instead of a picker override', (
+ tester,
+ ) async {
+ final prefs = await _prefs();
+ final theme = AppTheme.light().copyWith(
+ colorScheme: lightColorScheme.copyWith(
+ surfaceContainerHighest: Colors.grey,
+ ),
+ bottomSheetTheme: const BottomSheetThemeData(
+ backgroundColor: Colors.green,
),
- findsOneWidget,
);
- expect(
- find.descendant(
- of: grid,
- matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ myPubkeyProvider.overrideWithValue('self'),
+ emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
+ customEmojiListProvider.overrideWithValue(_customEmoji),
+ ],
+ child: MaterialApp(
+ theme: theme,
+ home: Scaffold(
+ body: Builder(
+ builder: (context) => FilledButton(
+ onPressed: () =>
+ showEmojiPicker(context: context, onSelect: (_) {}),
+ child: const Text('Open picker'),
+ ),
+ ),
+ ),
+ ),
),
- findsOneWidget,
+ );
+
+ await tester.tap(find.text('Open picker'));
+ await tester.pumpAndSettle();
+
+ final ancestorMaterials = find
+ .ancestor(
+ of: find.byType(EmojiPickerSheet),
+ matching: find.byType(Material),
+ )
+ .evaluate()
+ .map((element) => element.widget as Material);
+ expect(
+ ancestorMaterials.map((material) => material.color),
+ contains(Colors.green),
+ );
+ expect(
+ ancestorMaterials.map((material) => material.color),
+ isNot(contains(Colors.grey)),
);
});
@@ -174,21 +338,27 @@ void main() {
await _pumpPicker(tester, prefs: await _prefs());
// The rail used to be a short left-aligned strip. Every section now gets
- // one evenly-sized slot across the same width the search field spans.
- final searchField = tester.getRect(
- find.byKey(const ValueKey('emoji-picker-search')),
- );
+ // one evenly-sized slot across the tray, while search shares its row with
+ // the close control above.
+ final picker = tester.getRect(find.byType(EmojiPickerSheet));
final people = tester.getRect(find.byTooltip('Smileys & People'));
final nature = tester.getRect(find.byTooltip('Animals & Nature'));
final custom = tester.getRect(find.byTooltip('Custom'));
+ final skinTone = tester.getRect(find.byTooltip('Skin tone'));
expect(nature.left, greaterThan(people.left));
expect(custom.left, greaterThan(nature.left));
expect(people.width, closeTo(nature.width, 0.5));
expect(people.width, closeTo(custom.width, 0.5));
- // First slot starts and last slot ends on the search field's edges.
- expect(people.left, closeTo(searchField.left, 0.5));
- expect(custom.right, closeTo(searchField.right, 0.5));
+ expect(people.width, closeTo(skinTone.width, 0.5));
+ expect(people.left, closeTo(picker.left + Grid.gutter, 0.5));
+ expect(skinTone.right, closeTo(picker.right - Grid.gutter, 0.5));
+ expect(
+ tester.getSize(
+ find.byKey(const ValueKey('emoji-skin-tone-dot-selected')),
+ ),
+ const Size.square(16),
+ );
});
testWidgets('tapping the rail scrolls the grid instead of replacing it', (
@@ -211,6 +381,37 @@ void main() {
expect(offset(), closeTo(28 + 25 * 40, 0.5));
});
+ testWidgets(
+ 'a bottom-clamped final section stays highlighted after a rail tap',
+ (tester) async {
+ await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
+ final colors = Theme.of(
+ tester.element(find.byType(EmojiPickerSheet)),
+ ).colorScheme;
+ Color iconColor(String tooltip) => tester
+ .widget