From ce56e34411d2940e70a6c0de653ffae36d334701 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 3 Aug 2026 12:28:50 -0700 Subject: [PATCH 001/134] fix(mobile): recover stale relay sessions (#4372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Fixes [this issue](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=b726c366abfe62429ee3cdcd34d0c0fb98c33c3ea053480585bed71745412b56): > I often don’t see my bot responses until after I post. they’re usually time stamped correctly so I think it’s just a refresh issue? ### What changed? Buzz Mobile now reconnects relay sessions after the app has remained backgrounded beyond the existing 5-second grace period, even when the session still reports a stale `connected` state. This makes resume recovery independent of whether iOS runs the grace timer before or after delivering `resumed`. Reconnection is now based on elapsed background time rather than a direct socket-health probe. - If the app was backgrounded for at least the 5-second grace period, the socket is presumed dead and the session reconnects regardless of reported status. - If it was backgrounded for less than that, a reported `connected` status is still trusted. In the sub-5-second window the socket is either genuinely alive, which is the common case for a momentary background, or it is dead and the client ping detects it within the two-interval worst case described below. That is now a degraded-latency path, not a silent-forever path. The mobile relay socket now uses `IOWebSocketChannel.connect` with a 30-second `pingInterval`. An unanswered ping closes the Dart socket through the existing disconnect and reconnect path. Detection takes up to two ping intervals, so about 60 seconds worst case, not 30. One interval of idleness elapses and a ping is sent, then a second interval elapses with no pong and the socket closes. Any inbound pong restarts the first stage, so the clock measures idleness rather than running on a fixed cadence. ### Why? Buzz iOS can sometimes stop showing new bot or agent responses after a phone has been locked for 5 to 10 minutes. When the user later posts a message, the missing responses can appear all at once. iOS may suspend Buzz before the short delayed cleanup that would normally close its connection has a chance to run. Before this change, Buzz trusted the resulting stale healthy status on resume and skipped reconnecting, so the missing responses stayed hidden until a later post exposed the dead connection. A state-machine test with a stubbed connection reproduced this reported pattern and showed that it matches this failure mode: the failed post triggered a reconnect that fetched the missing messages. The same test also checked the other candidate explanation, the bug tracked in [#3053](https://github.com/block/buzz/pull/3053), where the relay has closed the app's subscription. That state does not produce the pattern. Posting succeeds and the user's own message appears, but nothing looks for the missed messages, so they stay hidden. The test confirmed that the missed messages were still available to fetch in that state, so the missing step was a trigger to fetch them. This was not an end-to-end reproduction on an iOS device or a live relay. The new resume check covers the normal lock and unlock path. If the app was backgrounded for less than the 5-second grace period, it still trusts a connection marked as healthy. A dead connection in that window is instead detected by the ping check, which can take up to about 60 seconds but prevents the app from remaining silently stuck. The ping only runs while iOS is running the app, so it does not detect a connection that died during suspension; the resume check owns the lock and unlock path. A pre-existing path also runs the same resume handling when network connectivity returns while the app is already in the foreground. Because the app was not backgrounded, this change does not alter that path, which still trusts a connection marked as healthy and relies on the slower ping check. Recovery from a subscription that the relay explicitly closes remains in [#3053](https://github.com/block/buzz/pull/3053), and the two changes overlap in one file. Changes to how missed messages are backfilled or replayed are out of scope. ### How is it tested? Full mobile suite at base and head. Both runs have the same known macOS-host-only failure in `ChannelDetailPage keeps follow mode off while a tall newest message stays visible` at line 1053: - Base: 1,021 passed, 1 skipped, 1 failed - Head: 1,025 passed, 1 skipped, 1 failed Added tests: - [`relay_session_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_session_test.dart): long-background resume reconnect and within-grace control - [`relay_socket_liveness_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_socket_liveness_test.dart): silent-peer disconnect and idle-but-healthy control Mutation checks confirm that removing elapsed-background resume recovery fails with one socket instead of two, and removing `pingInterval` leaves the silent peer connected. Restored production code passes both mutations' regression tests and the healthy idle control. Signed-off-by: Tom Brow Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> --- mobile/lib/shared/relay/relay_session.dart | 21 +++- mobile/lib/shared/relay/relay_socket.dart | 12 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../test/shared/relay/relay_session_test.dart | 119 +++++++++++++++++- .../relay/relay_socket_liveness_test.dart | 111 ++++++++++++++++ 6 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 mobile/test/shared/relay/relay_socket_liveness_test.dart diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 877bd15e82..1c5c305b4c 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -89,17 +89,20 @@ class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, RelaySocketFactory socketFactory = RelaySocket.new, + DateTime Function()? now, RelayRateLimitGate? rateLimitGate, RelayTimerFactory retryTimerFactory = Timer.new, Future Function(Duration) replayDelay = Future.delayed, }) : _httpClient = httpClient, _socketFactory = socketFactory, + _now = now ?? DateTime.now, _rateLimitGate = rateLimitGate ?? RelayRateLimitGate(), _retryTimerFactory = retryTimerFactory, _replayDelay = replayDelay; final http.Client? _httpClient; final RelaySocketFactory _socketFactory; + final DateTime Function() _now; final RelayRateLimitGate _rateLimitGate; final RelayTimerFactory _retryTimerFactory; final Future Function(Duration) _replayDelay; @@ -111,6 +114,7 @@ class RelaySessionNotifier extends Notifier { static const _replayBatchSize = 8; static const _replayInterBatchDelay = Duration(milliseconds: 50); static const _maxRecentDeliveryKeys = 5000; + static const _backgroundGraceDuration = Duration(seconds: 5); RelaySocket? _socket; final Map _historySubscriptions = {}; @@ -122,6 +126,7 @@ class RelaySessionNotifier extends Notifier { Timer? _reconnectTimer; Timer? _flushTimer; Timer? _backgroundGraceTimer; + DateTime? _backgroundedAt; int _reconnectDelayMs = _baseReconnectDelayMs; int _subIdCounter = 0; bool _disposed = false; @@ -394,8 +399,9 @@ class RelaySessionNotifier extends Notifier { /// Called by the app lifecycle provider when the app goes to background. void onAppPaused() { + _backgroundedAt = _now(); _backgroundGraceTimer?.cancel(); - _backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow); + _backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow); } void _pauseNow() { @@ -411,12 +417,18 @@ class RelaySessionNotifier extends Notifier { /// Called by the app lifecycle provider when the app returns to foreground. void onAppResumed() { _paused = false; + final backgroundedAt = _backgroundedAt; + _backgroundedAt = null; _backgroundGraceTimer?.cancel(); _backgroundGraceTimer = null; - // If still connected, nothing to do — the socket survived the background - // grace window. - if (state.status == SessionStatus.connected) return; + final backgroundedLongEnoughToRequireReconnect = + backgroundedAt != null && + _now().difference(backgroundedAt) >= _backgroundGraceDuration; + if (!backgroundedLongEnoughToRequireReconnect && + state.status == SessionStatus.connected) { + return; + } // Cancel any in-flight reconnect backoff timer so we reconnect immediately // instead of waiting for the (possibly large) exponential delay. @@ -908,6 +920,7 @@ class RelaySessionNotifier extends Notifier { _reconnectTimer?.cancel(); _flushTimer?.cancel(); _backgroundGraceTimer?.cancel(); + _backgroundedAt = null; _cancelAllClosedRetries(); _rateLimitGate.reset(); _visibleChannelsByOwner.clear(); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 267b030391..5b23279e81 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; @@ -30,6 +31,12 @@ Exception classifyRelayAuthFailure(String message) { } class RelaySocket { + /// Interval for sending a ping and awaiting its pong before disconnecting. + static const pingInterval = Duration(seconds: 30); + + @visibleForTesting + static Duration debugPingInterval = pingInterval; + final String _wsUrl; final String? _nsec; final void Function(List message) _onMessage; @@ -63,7 +70,10 @@ class RelaySocket { _state = SocketState.connecting; try { - _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); + _channel = IOWebSocketChannel.connect( + Uri.parse(_wsUrl), + pingInterval: debugPingInterval, + ); await _channel!.ready; } catch (e) { _state = SocketState.disconnected; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 05ccc2ea0f..6287e4c86c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -274,7 +274,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct dev" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 42b7935582..41d2a0aeb8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -47,6 +47,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 mocktail: ^1.0.4 diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index b97df0849d..826e234400 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -432,6 +432,120 @@ void main() { expect(session.state.status, SessionStatus.disconnected); }); + test( + 'resume reconnects a stale connected session after a long pause', + () async { + final sockets = <_ControlledRelaySocket>[]; + final keychain = nostr.Keys.generate(); + var now = DateTime(2026, 8, 2, 12); + final session = RelaySessionNotifier( + now: () => now, + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _ControlledRelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + sockets.add(socket); + return socket; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + ), + ), + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + sockets.single.connectSuccessfully(); + + session.onAppPaused(); + now = now.add(const Duration(minutes: 5)); + session.onAppResumed(); + await Future.delayed(Duration.zero); + + expect(sockets, hasLength(2)); + expect(sockets.first.disposeCalls, 1); + expect(session.state.status, SessionStatus.reconnecting); + }, + ); + + test( + 'resume keeps a connected session within the background grace period', + () async { + final sockets = <_ControlledRelaySocket>[]; + final keychain = nostr.Keys.generate(); + var now = DateTime(2026, 8, 2, 12); + final session = RelaySessionNotifier( + now: () => now, + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _ControlledRelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + sockets.add(socket); + return socket; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + ), + ), + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + sockets.single.connectSuccessfully(); + + session.onAppPaused(); + now = now.add(const Duration(seconds: 4)); + session.onAppResumed(); + await Future.delayed(Duration.zero); + + expect(sockets, hasLength(1)); + expect(sockets.single.disposeCalls, 0); + expect(session.state.status, SessionStatus.connected); + }, + ); + test('delivers the same live event to each matching subscription', () async { final session = RelaySessionNotifier(); final firstEvents = []; @@ -1143,6 +1257,7 @@ class _AuthenticatedAuthNotifier extends AuthNotifier { class _ControlledRelaySocket extends RelaySocket { final void Function() _connected; final void Function(Object? error) _disconnected; + int disposeCalls = 0; _ControlledRelaySocket({ required super.wsUrl, @@ -1157,7 +1272,9 @@ class _ControlledRelaySocket extends RelaySocket { Future connect() async {} @override - void dispose() {} + void dispose() { + disposeCalls++; + } void connectSuccessfully() => _connected(); diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart new file mode 100644 index 0000000000..835ebc2b61 --- /dev/null +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A server that completes the WS handshake then never speaks again: no pongs, +/// no close frame. Only a client-side ping timeout can notice. +Future _silentAfterHandshakeServer() async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + server.listen((client) { + client.listen( + (data) { + final match = RegExp( + r'Sec-WebSocket-Key: (.*)\r\n', + caseSensitive: false, + ).firstMatch(String.fromCharCodes(data)); + if (match == null) return; + final accept = base64.encode( + sha1 + .convert( + utf8.encode( + '${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + ), + ) + .bytes, + ); + client.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\nConnection: Upgrade\r\n' + 'Sec-WebSocket-Accept: $accept\r\n\r\n', + ); + }, + onError: (_) {}, + onDone: () {}, + ); + }); + return server; +} + +void main() { + const testPingInterval = Duration(milliseconds: 150); + + setUp(() { + RelaySocket.debugPingInterval = testPingInterval; + }); + + tearDown(() { + RelaySocket.debugPingInterval = RelaySocket.pingInterval; + }); + + test('detects a peer that stops answering pings', () async { + final server = await _silentAfterHandshakeServer(); + + final disconnected = Completer(); + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (error) { + if (!disconnected.isCompleted) disconnected.complete(error); + }, + ); + unawaited(socket.connect()); + + var detected = true; + try { + await disconnected.future.timeout(testPingInterval * 4); + } on TimeoutException { + detected = false; + } + + expect( + detected, + isTrue, + reason: + 'RelaySocket must surface an unanswered ping through onDisconnected', + ); + + socket.dispose(); + await server.close(); + }); + + test('keeps an idle but healthy peer connected', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.transform(WebSocketTransformer()).listen((ws) { + // A healthy relay answers pings without sending application data. + ws.listen((_) {}, onError: (_) {}, onDone: () {}); + }); + + var tornDown = false; + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) => tornDown = true, + ); + unawaited(socket.connect()); + + await Future.delayed(testPingInterval * 4); + + expect(tornDown, isFalse); + + await socket.disconnect(); + await server.close(force: true); + }); +} From e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 14:03:26 -0600 Subject: [PATCH 002/134] ci: add guarded desktop release cache prewarm (#4575) ## Summary Gate 1 only for desktop release caching: - replaces canary `rust-cache` use with explicit exact-key `actions/cache/restore` + `save` - computes keys after `cargo update --workspace`, including platform, target, Rust toolchain, Cargo manifests/locks, profile/features, and native-toolchain inputs - normalizes only the desktop package version so a trusted `main` canary can warm an otherwise identical release tag - excludes Tauri bundle directories, so installers and signed artifacts are never cached - adds a restore-only `cache-proof-*` tag workflow that fails unless tag scope sees the exact default-branch cache - adds contract tests that enforce no release-workflow cache change in Gate 1 `release.yml` is intentionally unchanged. A cache miss remains the current cold canary build; the release path cannot be affected by merging this PR. ## Validation - `scripts/test-desktop-release-cache-key.sh` - `scripts/test-desktop-release-cache-workflow.sh` - `scripts/test-release-ref-contract.sh` - Ruby YAML parse of all four changed workflows - `git diff --check` - pre-push `branch-skew` ## Post-merge proof plan 1. Run each canary cold on trusted `main`, recording cache size/save time and fresh artifact inventory. 2. Run each canary warm, requiring the exact-key hit and recording restore/build time. 3. Create a disposable `cache-proof-*` tag at that same trusted `main` SHA and dispatch **Desktop release cache tag-scope proof** from the tag. 4. Do not begin Gate 2 or modify `release.yml` unless the exact tag-scope restore succeeds and cache transfer economics are favorable. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../workflows/desktop-release-cache-proof.yml | 164 ++++++++++++++++++ .github/workflows/linux-canary.yml | 66 +++++-- .github/workflows/macos-intel-canary.yml | 126 ++++++++++++++ .github/workflows/signed-macos-canary.yml | 60 +++++-- .github/workflows/windows-canary.yml | 68 ++++++-- scripts/desktop-native-toolchain-id.sh | 34 ++++ scripts/desktop-release-cache-key.py | 85 +++++++++ scripts/test-desktop-release-cache-key.sh | 29 ++++ .../test-desktop-release-cache-workflow.sh | 77 ++++++++ scripts/test-release-ref-contract.sh | 2 + 10 files changed, 672 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/desktop-release-cache-proof.yml create mode 100644 .github/workflows/macos-intel-canary.yml create mode 100755 scripts/desktop-native-toolchain-id.sh create mode 100755 scripts/desktop-release-cache-key.py create mode 100755 scripts/test-desktop-release-cache-key.sh create mode 100755 scripts/test-desktop-release-cache-workflow.sh diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml new file mode 100644 index 0000000000..cf9c8e7827 --- /dev/null +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -0,0 +1,164 @@ +name: Desktop release cache tag-scope proof + +# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all +# four canaries. Every job restores only and requires an exact cache hit. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos: + name: Prove macOS ${{ matrix.target }} cache visibility + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + features: mesh-llm + - target: x86_64-apple-darwin + features: default + steps: + - name: Require cache proof tag + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + CACHE_TARGET: ${{ matrix.target }} + CACHE_FEATURES: ${{ matrix.features }} + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + linux: + name: Prove Linux cache visibility + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + timeout-minutes: 15 + defaults: + run: + shell: bash + steps: + - name: Require cache proof tag and install release native tools + run: | + [[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; } + apt-get update + apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + windows: + name: Prove Windows cache visibility + if: github.repository == 'block/buzz' + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Require cache proof tag + shell: bash + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Patch proof dependency graph + shell: bash + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + shell: bash + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index e1625f4ec8..d8b10032b2 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -7,8 +7,8 @@ name: Linux Canary # Design notes vs. signed-macos-canary.yml: # - fix-appimage.sh is run without signing env vars; the script detects # their absence and skips re-signing, repacking only (documented inline). -# - mold linker added (rui314/setup-mold) to reduce link time, matching -# the Linux Rust CI jobs in ci.yml. +# - Build tools match release.yml; cache keys derive the concrete linker and +# native library identity rather than assuming the moving runner image. # - pnpm store restore/save pattern mirrors ci.yml:149-196. on: workflow_dispatch: @@ -83,18 +83,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to linux-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: linux-canary-release - - name: Install appimagetool run: | case "$(uname -m)" in @@ -154,6 +142,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-unknown-linux-gnu \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -190,6 +210,24 @@ jobs: fi bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml new file mode 100644 index 0000000000..35b05313c9 --- /dev/null +++ b/.github/workflows/macos-intel-canary.yml @@ -0,0 +1,126 @@ +name: macOS Intel Canary + +# Produces an unsigned Intel DMG from trusted main. Its release-equivalent +# Cargo state warms the distinct x86_64 release target without signing or +# publishing anything. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build macOS Intel canary + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 60 + env: + TARGET: x86_64-apple-darwin + steps: + - name: Require main + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Add Rust target + run: rustup target add "$TARGET" + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Derive and patch canary version + run: | + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}" + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target "$TARGET" \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + + - name: Generate non-updating bundle config + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + {"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}} + JSON + + - name: Build Intel sidecars + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build unsigned Intel DMG + run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" + + - name: Locate fresh Intel DMG + id: artifact + run: | + DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1) + [[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; } + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + - name: Upload Intel canary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-macos-intel-canary-${{ github.sha }} + path: ${{ steps.artifact.outputs.dmg }} + if-no-files-found: error + retention-days: 7 + + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 0a3a513eef..5957f4785d 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -34,16 +34,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to macos-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: macos-canary-release - - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -78,6 +68,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target aarch64-apple-darwin \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -210,6 +232,24 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers or signed artifacts from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..7093efd2dc 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -46,24 +46,9 @@ jobs: shell: bash run: rustup target add "$TARGET" - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to windows-canary-release so canary - # runs warm each other without colliding with CI's debug-profile key - # (CI windows job does clippy/check, not --release). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: windows-canary-release - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - # Disable setup-node's built-in cache: we manage the pnpm store cache - # explicitly below (restore before install, save after) to mirror the - # pattern used by ci.yml and to keep caching logic consistent across - # all three canary workflows. package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -108,6 +93,40 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-pc-windows-msvc \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config shell: bash run: | @@ -152,6 +171,25 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + shell: bash + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/scripts/desktop-native-toolchain-id.sh b/scripts/desktop-native-toolchain-id.sh new file mode 100755 index 0000000000..b3c5f8bb75 --- /dev/null +++ b/scripts/desktop-native-toolchain-id.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +platform=${1:?usage: desktop-native-toolchain-id.sh } +case "$platform" in + macos) + { + sw_vers + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + xcrun --sdk macosx --show-sdk-version + xcrun clang --version + } ;; + linux) + { + cat /etc/os-release + dpkg-query -W -f='${Package}=${Version}\n' \ + build-essential libasound2-dev libayatana-appindicator3-dev \ + libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev \ + libxdo-dev patchelf pkg-config + gcc -dumpfullversion -dumpversion + gcc -dumpmachine + ld --version + } ;; + windows) + { + cmd.exe //c ver + powershell.exe -NoProfile -Command '$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"; & $vswhere -latest -products * -property installationVersion; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022" -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\Include" -Directory | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022\*\VC\Tools\MSVC" -Directory | Select-Object -ExpandProperty Name' + cmake --version + } ;; + *) + echo "unsupported native toolchain platform: $platform" >&2 + exit 1 ;; +esac | tr -d '\r' | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' diff --git a/scripts/desktop-release-cache-key.py b/scripts/desktop-release-cache-key.py new file mode 100755 index 0000000000..736b3fcfa9 --- /dev/null +++ b/scripts/desktop-release-cache-key.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Compute an exact, version-agnostic Cargo release cache key.""" + +from __future__ import annotations + +import argparse +import hashlib +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DESKTOP_MANIFEST = pathlib.Path("desktop/src-tauri/Cargo.toml") +DESKTOP_LOCK = pathlib.Path("desktop/src-tauri/Cargo.lock") + + +def normalized(path: pathlib.Path, data: bytes) -> bytes: + text = data.decode() + if path == DESKTOP_MANIFEST: + text, count = re.subn( + r'(?ms)(^\[package\].*?^version\s*=\s*)"[^"]+"', + r'\1""', + text, + count=1, + ) + if count != 1: + raise ValueError(f"could not normalize package version in {path}") + elif path == DESKTOP_LOCK: + text, count = re.subn( + r'(?ms)(^name = "buzz-desktop"\nversion = )"[^"]+"', + r'\1""', + text, + count=1, + ) + if count != 1: + raise ValueError(f"could not normalize package version in {path}") + return text.encode() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--platform", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--features", default="default") + parser.add_argument("--native-inputs", required=True) + args = parser.parse_args() + + manifest_output = subprocess.check_output( + ["git", "ls-files", "*Cargo.toml"], cwd=ROOT, text=True + ) + paths = [ROOT / path for path in manifest_output.splitlines()] + paths += [ROOT / "Cargo.lock", ROOT / DESKTOP_LOCK, ROOT / "rust-toolchain.toml"] + cargo_config = ROOT / ".cargo/config.toml" + if cargo_config.exists(): + paths.append(cargo_config) + + digest = hashlib.sha256() + descriptors = { + "schema": "desktop-rust-release-v1", + "platform": args.platform, + "target": args.target, + "profile": "release", + "features": args.features, + "native-inputs": args.native_inputs, + "rustc": subprocess.check_output(["rustc", "-Vv"], text=True).strip(), + } + for name, value in sorted(descriptors.items()): + digest.update(f"{name}\0{value}\0".encode()) + + for absolute in sorted(set(paths)): + relative = absolute.relative_to(ROOT) + digest.update(str(relative).encode() + b"\0") + digest.update(normalized(relative, absolute.read_bytes()) + b"\0") + + print(f"desktop-rust-release-v1-{args.platform}-{args.target}-{digest.hexdigest()}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/test-desktop-release-cache-key.sh b/scripts/test-desktop-release-cache-key.sh new file mode 100755 index 0000000000..0b91b3e281 --- /dev/null +++ b/scripts/test-desktop-release-cache-key.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +key_script="scripts/desktop-release-cache-key.py" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cp -R "$repo_root"/. "$tmp/repo" +cd "$tmp/repo" + +args=(--platform Linux --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs ubuntu-24.04-mold) +original=$("$key_script" "${args[@]}") +python3 - <<'PY' +from pathlib import Path +manifest = Path("desktop/src-tauri/Cargo.toml") +manifest.write_text(manifest.read_text().replace('version = "0.5.4"', 'version = "9.8.7"', 1)) +lock = Path("desktop/src-tauri/Cargo.lock") +text = lock.read_text() +start = text.index('name = "buzz-desktop"') +version = text.index('version = "0.5.4"', start) +lock.write_text(text[:version] + 'version = "9.8.7"' + text[version + len('version = "0.5.4"'):]) +PY +version_only=$("$key_script" "${args[@]}") +[[ "$original" == "$version_only" ]] || { echo "desktop version changed cache key" >&2; exit 1; } +printf '\n# dependency input\n' >> crates/buzz-acp/Cargo.toml +dependency_changed=$("$key_script" "${args[@]}") +[[ "$original" != "$dependency_changed" ]] || { echo "dependency manifest did not change cache key" >&2; exit 1; } +[[ "$original" == desktop-rust-release-v1-Linux-x86_64-unknown-linux-gnu-* ]] || { echo "unexpected key: $original" >&2; exit 1; } +echo "desktop release cache key contract passed" diff --git a/scripts/test-desktop-release-cache-workflow.sh b/scripts/test-desktop-release-cache-workflow.sh new file mode 100755 index 0000000000..67f054a603 --- /dev/null +++ b/scripts/test-desktop-release-cache-workflow.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +release="$root/.github/workflows/release.yml" +proof="$root/.github/workflows/desktop-release-cache-proof.yml" +canaries=( + "$root/.github/workflows/signed-macos-canary.yml" + "$root/.github/workflows/macos-intel-canary.yml" + "$root/.github/workflows/windows-canary.yml" + "$root/.github/workflows/linux-canary.yml" +) + +if grep -q 'desktop-rust-release-v1\|desktop-release-cache-key' "$release"; then + echo "Gate 1 must not alter the release cache path" >&2 + exit 1 +fi +for workflow in "${canaries[@]}"; do + grep -q 'refs/heads/main' "$workflow" + grep -q 'desktop-native-toolchain-id.sh' "$workflow" + grep -q 'steps.native_toolchain.outputs.id' "$workflow" + grep -q 'actions/cache/restore@' "$workflow" + grep -q 'actions/cache/save@' "$workflow" + grep -q 'steps.rust_cache.outputs.cache-hit' "$workflow" + grep -q '!desktop/src-tauri/target/\*\*/release/bundle' "$workflow" + if grep -q 'restore-keys:.*desktop-rust\|Swatinem/rust-cache' "$workflow"; then + echo "release Cargo cache must use split actions with no fallback: $workflow" >&2 + exit 1 + fi +done + +# GitHub expressions must enter cache-key steps through env, never by direct +# interpolation into generated shell scripts. This blocks shell injection if a +# matrix or upstream output ever becomes attacker-controlled. +python3 - "$proof" "${canaries[@]}" <<'PY' +import pathlib +import re +import sys + +for filename in sys.argv[1:]: + text = pathlib.Path(filename).read_text() + steps = re.findall( + r"(?ms)^ - name: Compute exact release cache key\n(.*?)(?=^ - (?:name:|uses:)|\Z)", + text, + ) + if not steps: + raise SystemExit(f"cache-key step missing: {filename}") + for step in steps: + run = re.search(r"(?ms)^ run: \|\n(.*?)(?=^ \S|\Z)", step) + if not run: + raise SystemExit(f"cache-key run block missing: {filename}") + if "${{" in run.group(1): + raise SystemExit(f"GitHub expression interpolated into cache-key shell: {filename}") + if "NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}" not in step: + raise SystemExit(f"native toolchain output not passed through env: {filename}") +PY + +# Producer/proof coverage must match all four release targets and features. +for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu x86_64-pc-windows-msvc; do + grep -q -- "$target" "$proof" || { echo "proof missing $target" >&2; exit 1; } +done +grep -q -- '--features mesh-llm' "$proof" +grep -q -- '--features default' "$proof" +[[ $(grep -c 'actions/cache/save@' "$proof") -eq 0 ]] +[[ $(grep -c 'Require exact cache hit' "$proof") -eq 3 ]] +grep -q 'refs/tags/cache-proof-' "$proof" + +# Linux producer must match release's default linker, not the CI-only mold path. +if grep -q 'setup-mold\|ubuntu-24.04-mold' "$root/.github/workflows/linux-canary.yml"; then + echo "Linux cache producer diverges from the release linker" >&2 + exit 1 +fi +if grep -q 'setup-mold' "$release"; then + echo "release linker changed; re-review cache equivalence" >&2 + exit 1 +fi + +echo "desktop release cache workflow contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 6722135bb8..8bfd6798ee 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -54,6 +54,8 @@ grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml" grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml" grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" "$repo_root/scripts/test-signed-canary-contract.sh" +"$repo_root/scripts/test-desktop-release-cache-key.sh" +"$repo_root/scripts/test-desktop-release-cache-workflow.sh" auto_tag="$repo_root/.github/workflows/auto-tag-on-release-pr-merge.yml" grep -q 'actions/create-github-app-token@' "$auto_tag" grep -q 'client-id:.*vars\.BUZZ_RELEASE_TAGGER_CLIENT_ID' "$auto_tag" From 5c98932c59ee5344e9e8c14525c51f3de16ad2c2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 13:05:47 -0700 Subject: [PATCH 003/134] feat(desktop): make onboarding model defaults skippable (#3968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Users can skip default model configuration during onboarding and finish it later in Settings → Agents. **Problem:** Requiring model defaults during onboarding can block users who are not ready to choose a harness, provider, or model. Skipping also needs to leave existing configuration untouched rather than persisting partial selections. **Solution:** Stage onboarding edits locally and persist them only when users choose Next or Back. A delayed Skip action advances without any configuration write, while a footer hint points users to the settings location for completing setup later.
File changes **desktop/src/features/onboarding/ui/DefaultConfigStep.tsx** Adds the skip action and future-settings hint, and makes model configuration transactional so Skip discards staged changes while Next and Back preserve the intended save behavior. **desktop/src/testing/e2eBridge.ts** Exposes model-config setter call counts so tests can distinguish a true zero-write skip from a write-and-rollback implementation. **desktop/tests/e2e/onboarding-agent-defaults.spec.ts** Covers skipping during loading and after staged edits, verifies zero persistence calls, and confirms Next and Back still commit changes.
## Reproduction steps 1. Start fresh onboarding and continue through harness setup to **Configure your default model settings**. 2. Change the selected harness or model, then choose **Skip for now**. 3. Confirm onboarding advances to **Join or create a community** and the prior global model configuration remains unchanged. 4. Return through onboarding and confirm **Next** saves the staged selection; confirm **Back** also preserves staged changes before returning. 5. Confirm the footer says model defaults can be configured later in **Settings → Agents**. --------- Signed-off-by: Taylor Ho Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- desktop/src/features/agents/AGENTS.md | 19 +- .../onboarding/ui/DefaultConfigStep.tsx | 235 +++++++++++------ .../onboarding/ui/MachineOnboardingFlow.tsx | 6 + .../onboarding/ui/saveCoalescer.test.mjs | 242 ------------------ .../features/onboarding/ui/saveCoalescer.ts | 96 ------- desktop/src/features/onboarding/ui/types.ts | 15 +- desktop/src/testing/e2eBridge.ts | 16 +- .../e2e/onboarding-agent-defaults.spec.ts | 219 +++++++++++++++- desktop/tests/helpers/bridge.ts | 2 + 9 files changed, 412 insertions(+), 438 deletions(-) delete mode 100644 desktop/src/features/onboarding/ui/saveCoalescer.test.mjs delete mode 100644 desktop/src/features/onboarding/ui/saveCoalescer.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9222c7032..f2eb7f285c 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -67,11 +67,17 @@ with a TypeScript lookup table or an id comparison in a component. 7. **Onboarding setup detects readiness; it does not select defaults.** The setup page derives visible and ready harnesses from the runtime catalog and only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`, and - its Finish gate consumes the shared renderer's `onValidityChange` signal — - a harness selection alone does not complete onboarding when the harness + sole onboarding surface that chooses `preferred_runtime`. Its complete draft + lives in machine-onboarding session state, so Back performs no write and + restores even incomplete edits when the user returns. Skip abandons that + draft and advances with zero config writes. Next is the only persistence + boundary: it consumes the shared renderer's `onValidityChange` signal, + disables editing while awaiting `set_global_agent_config`, advances only on + success, and leaves the draft in place with a retryable inline error on + failure. A harness selection alone does not enable Next when the harness requires provider/model/credential config (e.g. buzz-agent with no - provider). Baked build env and runtime-file config satisfy the gate. + provider). Baked build env and runtime-file config satisfy the gate. Drafts + intentionally do not survive an app restart. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty @@ -168,8 +174,9 @@ with a TypeScript lookup table or an id comparison in a component. plus both resolvers, including unknown-reads-as-local and blank-`runOn`-is-not-a-provider. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior - acceptance coverage for readiness, failure states, defaults, navigation, - successful-empty vs failed optional-model discovery, and persistence races. + acceptance coverage for readiness, failure states, defaults, session-draft + restoration, zero-write Skip, Next save failure/retry, navigation, and + successful-empty vs failed optional-model discovery. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 50887f08aa..78a7a32db8 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -10,7 +10,6 @@ import { } from "@/features/agents/ui/AgentConfigFields"; import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; -import { createSaveCoalescer } from "./saveCoalescer"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { getGlobalAgentConfig, @@ -32,11 +31,12 @@ import { getReadyOnboardingRuntimes, getVisibleOnboardingRuntimes, } from "./onboardingRuntimeSelection"; -import type { DefaultConfigStepActions } from "./types"; +import type { DefaultConfigDraft, DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { actions: DefaultConfigStepActions; direction: OnboardingTransitionDirection; + draft: DefaultConfigDraft | null; readyRuntimeIds: readonly string[]; }; @@ -46,28 +46,40 @@ function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { } function AgentDefaultsSection({ + draft, + isPending, + onDraftChange, onPersistenceStateChange, readyRuntimeIds, }: { + draft: DefaultConfigDraft | null; + isPending: boolean; + onDraftChange: (draft: DefaultConfigDraft) => void; onPersistenceStateChange: (state: { canComplete: boolean; - flush: () => Promise; + commit: () => Promise; }) => void; readyRuntimeIds: readonly string[]; }) { const runtimesQuery = useAcpRuntimesQuery(); - const [config, setConfig] = - React.useState(EMPTY_GLOBAL_CONFIG); - const [isLoading, setIsLoading] = React.useState(true); - const [isCustomProvider, setIsCustomProvider] = React.useState(false); - const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + const initialDraftRef = React.useRef(draft); + const [config, setConfig] = React.useState( + initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, + ); + const [isLoading, setIsLoading] = React.useState( + initialDraftRef.current === null, + ); + const [isCustomProvider, setIsCustomProvider] = React.useState( + initialDraftRef.current?.isCustomProvider ?? false, + ); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState( + initialDraftRef.current?.isCustomModelEditing ?? false, + ); const [bakedEnv, setBakedEnv] = React.useState([]); - const coalescerRef = React.useRef<{ - enqueue: (value: GlobalAgentConfig) => void; - flush: () => Promise; - cancel: () => void; - } | null>(null); - const [isSaving, setIsSaving] = React.useState(false); + const configRef = React.useRef( + initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, + ); + const isDirtyRef = React.useRef(initialDraftRef.current?.isDirty ?? false); const [configIsValid, setConfigIsValid] = React.useState(false); React.useEffect(() => { @@ -81,7 +93,11 @@ function AgentDefaultsSection({ if (unmounted) return; - if (configResult.status === "fulfilled") { + if ( + initialDraftRef.current === null && + configResult.status === "fulfilled" + ) { + configRef.current = configResult.value; setConfig(configResult.value); } if (bakedEnvResult.status === "fulfilled") { @@ -92,25 +108,8 @@ function AgentDefaultsSection({ void loadDefaults(); - // The coalescer serializes autosaves and drains any edit that arrived - // while a previous save was in flight. Cancel on unmount so a slow - // in-flight request never calls setState on an unmounted component. - const coalescer = createSaveCoalescer( - // set_global_agent_config returns a save result (config + restart - // counts); the coalescer round-trips the persisted config only. - async (next) => (await setGlobalAgentConfig(next)).config, - (saving) => { - if (!unmounted) setIsSaving(saving); - }, - (saved) => { - if (!unmounted) setConfig(saved); - }, - ); - coalescerRef.current = coalescer; - return () => { unmounted = true; - coalescer.cancel(); }; }, []); @@ -160,15 +159,33 @@ function AgentDefaultsSection({ [readyRuntimes], ); + const updateDraft = React.useCallback( + (next: GlobalAgentConfig, overrides: Partial = {}) => { + isDirtyRef.current = overrides.isDirty ?? true; + configRef.current = next; + setConfig(next); + onDraftChange({ + config: next, + isCustomModelEditing, + isCustomProvider, + isDirty: isDirtyRef.current, + ...overrides, + }); + }, + [isCustomModelEditing, isCustomProvider, onDraftChange], + ); + const handleHarnessChange = React.useCallback( (runtimeId: string) => { const next = resetConfigForHarnessChange(config, runtimeId); setIsCustomModelEditing(false); setIsCustomProvider(false); - setConfig(next); - coalescerRef.current?.enqueue(next); + updateDraft(next, { + isCustomModelEditing: false, + isCustomProvider: false, + }); }, - [config], + [config, updateDraft], ); React.useEffect(() => { @@ -182,28 +199,34 @@ function AgentDefaultsSection({ selectedRuntimeId, ]); - const flushPersistence = React.useCallback( - () => coalescerRef.current?.flush() ?? Promise.resolve(), - [], - ); + const commitPersistence = React.useCallback(async () => { + if (!isDirtyRef.current) return; + const saved = await setGlobalAgentConfig(configRef.current); + isDirtyRef.current = false; + configRef.current = saved.config; + setConfig(saved.config); + }, []); React.useEffect(() => { onPersistenceStateChange({ // configIsValid comes from AgentConfigFields' onValidityChange and // covers model + provider credentials — a harness selection alone is // not a working default (e.g. buzz-agent with no provider configured). - canComplete: selectedRuntimeId.length > 0 && configIsValid && !isSaving, - flush: flushPersistence, + canComplete: selectedRuntimeId.length > 0 && configIsValid, + commit: commitPersistence, }); }, [ + commitPersistence, configIsValid, - flushPersistence, - isSaving, onPersistenceStateChange, selectedRuntimeId, ]); return ( -
+
{configSurfaceLoading ? (
@@ -240,15 +263,25 @@ function AgentDefaultsSection({ config={config} isCustomModelEditing={isCustomModelEditing} isCustomProvider={isCustomProvider} - onConfigChange={(next) => { - // Always apply optimistically so the UI never reverts mid-save, - // then enqueue the persist — the coalescer serialises multiple - // rapid edits into a single trailing request. - setConfig(next); - coalescerRef.current?.enqueue(next); + onConfigChange={updateDraft} + onCustomModelEditingChange={(next) => { + setIsCustomModelEditing(next); + onDraftChange({ + config: configRef.current, + isCustomModelEditing: next, + isCustomProvider, + isDirty: isDirtyRef.current, + }); + }} + onIsCustomProviderChange={(next) => { + setIsCustomProvider(next); + onDraftChange({ + config: configRef.current, + isCustomModelEditing, + isCustomProvider: next, + isDirty: isDirtyRef.current, + }); }} - onCustomModelEditingChange={setIsCustomModelEditing} - onIsCustomProviderChange={setIsCustomProvider} onValidityChange={setConfigIsValid} placeholderClassName="text-foreground/70" runtimeFileConfig={runtimeFileConfig} @@ -259,7 +292,7 @@ function AgentDefaultsSection({ />
)} -
+ ); } @@ -271,28 +304,39 @@ function AgentDefaultsSection({ export function DefaultConfigStep({ actions, direction, + draft, readyRuntimeIds, }: DefaultConfigStepProps) { const [persistenceState, setPersistenceState] = React.useState<{ canComplete: boolean; - flush: () => Promise; - }>({ canComplete: false, flush: () => Promise.resolve() }); - const [completionError, setCompletionError] = React.useState( - null, - ); - const [isCompleting, setIsCompleting] = React.useState(false); + commit: () => Promise; + }>({ canComplete: false, commit: () => Promise.resolve() }); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); const handleComplete = React.useCallback(async () => { - setIsCompleting(true); - setCompletionError(null); + if (isSaving) return; + setIsSaving(true); + setSaveError(null); try { - await persistenceState.flush(); + await persistenceState.commit(); + actions.discardDraft(); actions.complete(); - } catch { - setCompletionError("Couldn't save your default harness. Try again."); - setIsCompleting(false); + } catch (cause) { + setSaveError( + cause instanceof Error + ? cause.message + : "Couldn’t save model settings.", + ); + } finally { + setIsSaving(false); } - }, [actions, persistenceState]); + }, [actions, isSaving, persistenceState]); + + const handleSkip = React.useCallback(() => { + actions.discardDraft(); + actions.complete(); + }, [actions]); return (
- {completionError ? ( -

- {completionError} -

- ) : null}
- + {/* Keep Next centered while the optional action sits beside it. */} +
+ + +
+ + {saveError ? ( +

+ Couldn’t save model settings. {saveError} Try again. +

+ ) : null} + +

+ Configure default models in{" "} + Settings → Agents after + setup. +

); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index cee17c68f8..693d1af058 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -33,6 +33,7 @@ import { import { OnboardingFooterProvider } from "./OnboardingFooter"; import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; import { SetupStep } from "./SetupStep"; +import type { DefaultConfigDraft } from "./types"; export type MachineOnboardingPage = | "identity" @@ -85,6 +86,8 @@ export function MachineOnboardingFlow({ IdentityStorage | undefined >(); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + const [defaultConfigDraft, setDefaultConfigDraft] = + React.useState(null); const [backupSubview, setBackupSubview] = React.useState("created"); const [backupDirection, setBackupDirection] = React.useState< @@ -381,8 +384,11 @@ export function MachineOnboardingFlow({ actions={{ back: () => setPage("setup"), complete: () => complete(selectedPubkey ?? undefined), + discardDraft: () => setDefaultConfigDraft(null), + updateDraft: setDefaultConfigDraft, }} direction="forward" + draft={defaultConfigDraft} readyRuntimeIds={readyRuntimeIds} /> )} diff --git a/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs b/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs deleted file mode 100644 index 02e23c131f..0000000000 --- a/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Unit tests for the save coalescer helper. - * - * Each test controls the in-flight save duration with a deferred promise so - * it can precisely verify interleaving: one edit during flight, multiple - * overwrites, cancellation, etc. - */ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createSaveCoalescer } from "./saveCoalescer.ts"; - -function deferred() { - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -// Drain one microtask queue turn so the async drain() loop can advance. -const tick = () => new Promise((r) => setTimeout(r, 0)); - -test("saveCoalescer_single_edit_is_persisted_and_saved_is_applied", async () => { - const persisted = []; - const saved = []; - - const coalescer = createSaveCoalescer( - async (v) => { - persisted.push(v); - return { ...v, fromServer: true }; - }, - () => {}, - (v) => saved.push(v), - ); - - coalescer.enqueue({ model: "claude" }); - await tick(); - await tick(); - - assert.equal(persisted.length, 1); - assert.equal(persisted[0].model, "claude"); - assert.equal(saved.length, 1); - assert.equal(saved[0].fromServer, true); -}); - -test("saveCoalescer_rapid_edits_coalesce_second_is_drained_after_first", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - // Second edit arrives while first save is still in flight. - coalescer.enqueue({ n: 1 }); - - d.resolve(); - await tick(); - await tick(); - - assert.deepEqual(persisted, [0, 1]); -}); - -test("saveCoalescer_three_rapid_edits_only_first_and_last_are_persisted", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); // overwritten before drain picks it up - coalescer.enqueue({ n: 2 }); // overwrites n:1 - - d.resolve(); - await tick(); - await tick(); - - // n:1 was never the pending value when the drain loop checked; only n:2. - assert.deepEqual(persisted, [0, 2]); -}); - -test("saveCoalescer_onSaved_suppressed_for_first_when_second_is_pending", async () => { - const d = deferred(); - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - return v; - }, - () => {}, - (v) => savedCalls.push(v.n), - ); - - coalescer.enqueue({ n: 0 }); - // Queue n:1 before n:0 resolves — onSaved for n:0 should be suppressed. - coalescer.enqueue({ n: 1 }); - - d.resolve(); - await tick(); - await tick(); - - // Only n:1 (the final save round) triggers onSaved. - assert.deepEqual(savedCalls, [1]); -}); - -test("saveCoalescer_isSaving_transitions_true_then_false", async () => { - const d = deferred(); - const states = []; - - const coalescer = createSaveCoalescer( - async (v) => { - await d.promise; - return v; - }, - (s) => states.push(s), - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - assert.deepEqual(states, [true]); - - d.resolve(); - await tick(); - await tick(); - - assert.deepEqual(states, [true, false]); -}); - -test("saveCoalescer_flush_waits_for_the_latest_pending_save", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); - let flushed = false; - const flush = coalescer.flush().then(() => { - flushed = true; - }); - await tick(); - assert.equal(flushed, false); - - d.resolve(); - await flush; - assert.deepEqual(persisted, [0, 1]); -}); - -test("saveCoalescer_flush_rejects_when_the_final_save_fails", async () => { - const coalescer = createSaveCoalescer( - async () => { - throw new Error("save failed"); - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - await assert.rejects(coalescer.flush(), /save failed/); -}); - -test("saveCoalescer_cancel_prevents_onSaved_and_onSaving_false", async () => { - const d = deferred(); - const states = []; - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - await d.promise; - return v; - }, - (s) => states.push(s), - (v) => savedCalls.push(v), - ); - - coalescer.enqueue({ n: 0 }); - coalescer.cancel(); - d.resolve(); - await tick(); - await tick(); - - // After cancel, neither onSaved nor onSaving(false) fire. - assert.equal(savedCalls.length, 0); - assert.equal(states.includes(false), false); -}); - -test("saveCoalescer_save_error_does_not_call_onSaved_but_drains_pending", async () => { - const d = deferred(); - const persisted = []; - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) { - await d.promise; - throw new Error("network error"); - } - persisted.push(v.n); - return v; - }, - () => {}, - (v) => savedCalls.push(v.n), - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); // pending while n:0 fails - - d.resolve(); - await tick(); - await tick(); - - // n:0 errored — no onSaved for it; n:1 was drained and saved. - assert.deepEqual(persisted, [1]); - assert.deepEqual(savedCalls, [1]); -}); diff --git a/desktop/src/features/onboarding/ui/saveCoalescer.ts b/desktop/src/features/onboarding/ui/saveCoalescer.ts deleted file mode 100644 index f34327453c..0000000000 --- a/desktop/src/features/onboarding/ui/saveCoalescer.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Creates an async save coalescer. - * - * When multiple calls to enqueue() arrive while a save is in flight, only - * the latest enqueued value is submitted per drain round — no edit is - * silently dropped and the final persisted state always reflects the most - * recent local change. - * - * Lifecycle: call cancel() on unmount so in-flight saves do not invoke - * callbacks after the owning component is gone. Call flush() before leaving - * a surface that must guarantee its latest optimistic value was persisted. - */ -export function createSaveCoalescer( - save: (value: T) => Promise, - onSaving: (isSaving: boolean) => void, - onSaved: (value: T) => void, -): { - enqueue: (value: T) => void; - flush: () => Promise; - cancel: () => void; -} { - let pending: T | undefined; - let hasPending = false; - let running = false; - let cancelled = false; - let finalError: unknown; - let flushWaiters: Array<{ - resolve: () => void; - reject: (error: unknown) => void; - }> = []; - - function settleFlushWaiters() { - const waiters = flushWaiters; - flushWaiters = []; - for (const waiter of waiters) { - if (finalError === undefined) waiter.resolve(); - else waiter.reject(finalError); - } - } - - async function drain() { - while (hasPending) { - const toSave = pending as T; - hasPending = false; - pending = undefined; - try { - const saved = await save(toSave); - finalError = undefined; - // Apply backend response only when no newer local edit is pending — - // a stale response must never overwrite fresher optimistic state. - if (!cancelled && !hasPending) { - onSaved(saved); - } - } catch (error) { - finalError = error; - } - } - running = false; - if (!cancelled) { - onSaving(false); - settleFlushWaiters(); - } - } - - return { - enqueue(value: T) { - pending = value; - hasPending = true; - if (running) return; - running = true; - finalError = undefined; - onSaving(true); - void drain(); - }, - flush() { - if (!running) { - return finalError === undefined - ? Promise.resolve() - : Promise.reject(finalError); - } - return new Promise((resolve, reject) => { - flushWaiters.push({ resolve, reject }); - }); - }, - cancel() { - cancelled = true; - hasPending = false; - pending = undefined; - const waiters = flushWaiters; - flushWaiters = []; - for (const waiter of waiters) { - waiter.reject(new Error("Save cancelled")); - } - }, - }; -} diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 443a1f3f4e..5216bfefa4 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -1,4 +1,8 @@ -import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + GlobalAgentConfig, + Profile, +} from "@/shared/api/types"; export type OnboardingPage = | "profile" @@ -62,9 +66,18 @@ export type SetupStepActions = { navigateToAgentSettings?: () => void; }; +export type DefaultConfigDraft = { + config: GlobalAgentConfig; + isCustomModelEditing: boolean; + isCustomProvider: boolean; + isDirty: boolean; +}; + export type DefaultConfigStepActions = { back: () => void; complete: () => void; + discardDraft: () => void; + updateDraft: (draft: DefaultConfigDraft) => void; }; export type SetupStepRuntimeState = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03d37ed72e..5cbfb03331 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -448,9 +448,11 @@ type E2eConfig = { * initial render gating around build defaults. 0/undefined = instant. */ bakedBuildEnvDelayMs?: number; /** Delay (ms) applied to `set_global_agent_config` so tests can observe - * autosave behaviour while a request is in flight. 0/undefined = instant. - * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ + * pending save behaviour. 0/undefined = instant. Alias of + * `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Sequenced save failures. A string rejects that call; null succeeds. */ + setGlobalAgentConfigErrors?: (string | null)[]; /** Errors returned by successive backup verification attempts. Null succeeds. */ backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ @@ -7333,6 +7335,7 @@ let installCallCount = 0; /** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */ const installCallCountByRuntime: Record = {}; let addChannelMembersCallCount = 0; +let setGlobalAgentConfigCallCount = 0; let mockGlobalAgentConfig: { env_vars: Record; provider: string | null; @@ -11643,7 +11646,16 @@ export function maybeInstallE2eTauriMocks() { } ); } + case "get_global_agent_config_set_call_count": + return setGlobalAgentConfigCallCount; case "set_global_agent_config": { + setGlobalAgentConfigCallCount += 1; + const saveErrors = activeConfig?.mock?.setGlobalAgentConfigErrors; + const saveError = + saveErrors?.[ + Math.min(setGlobalAgentConfigCallCount - 1, saveErrors.length - 1) + ]; + if (saveError) throw new Error(saveError); // Echo back the submitted config as the saved value (mirrors the // backend's strip-on-write pass in tests where all values are already // non-empty). The invoke payload wraps it as { config }. diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index dbd726d179..b3462a4903 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -57,6 +57,24 @@ async function readSavedRuntime(page: Parameters[0]) { }); } +async function readGlobalConfigSetterCallCount( + page: Parameters[0], +) { + return await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "get_global_agent_config_set_call_count", + null, + ); + }); +} + test("setup shows all bundled harnesses as detected", async ({ page }) => { await installMockBridge( page, @@ -528,21 +546,127 @@ test("defaults keeps model control when optional harness discovery fails", async await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); -test("defaults Back returns to harness setup", async ({ page }) => { +test("defaults can be skipped while loading without persisting configuration", async ({ + page, +}) => { await installMockBridge( page, { acpRuntimesCatalog: [ runtime("claude", "available", { status: "logged_in" }), ], + bakedBuildEnvDelayMs: 500, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); + + await expect(page.getByText("Loading…")).toBeVisible(); + await page.getByTestId("onboarding-config-skip").click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); +}); + +test("defaults stages auto-selection and edits without writing when skipped", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + await page.getByTestId("global-agent-model").click(); + await page + .getByTestId("global-agent-model-option-claude-opus-4-20250514") + .click(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); + await expect( + page.getByText( + "Configure default models in Settings → Agents after setup.", + ), + ).toBeVisible(); + + await page.getByTestId("onboarding-config-skip").click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); +}); + +test("Back preserves incomplete defaults draft without writing", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime("claude", "available", { status: "logged_in" }), + ], + discoverAgentModels: { + models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }], + supportsSwitching: true, + }, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page + .getByTestId("global-agent-default-harness-option-buzz-agent") + .click(); + await page.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await page.getByTestId("onboarding-back").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); + + await page.getByTestId("onboarding-setup-next").click(); + await expect(harness).toHaveText("Buzz"); + await expect(page.getByTestId("global-agent-provider")).toHaveText( + "Anthropic", + ); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); }); test("defaults auto-selects the only ready visible harness", async ({ @@ -575,12 +699,10 @@ test("defaults auto-selects the only ready visible harness", async ({ "Claude Code", ); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); - await expect.poll(() => readSavedRuntime(page)).toBe("claude"); + expect(await readSavedRuntime(page)).toBeNull(); }); -test("Finish waits for the latest rapid harness choice to persist", async ({ - page, -}) => { +test("Next persists the latest staged harness choice", async ({ page }) => { await installMockBridge( page, { @@ -608,13 +730,94 @@ test("Finish waits for the latest rapid harness choice to persist", async ({ await harness.click(); await page.getByTestId("global-agent-default-harness-option-codex").click(); const finish = page.getByTestId("onboarding-finish"); - await expect(finish).toBeDisabled(); - await expect(finish).toBeEnabled({ timeout: 2_000 }); + await expect(finish).toBeEnabled(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); await finish.click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + await expect.poll(() => readSavedRuntime(page)).toBe("codex"); +}); + +test("Next shows saving state and advances only after persistence", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + setGlobalAgentConfigDelayMs: 500, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + await page.getByTestId("onboarding-finish").click(); + + await expect(page.getByTestId("onboarding-finish")).toHaveText("Saving…"); + await expect(page.getByTestId("onboarding-config-skip")).toBeDisabled(); + await expect(page.getByTestId("onboarding-back")).toBeDisabled(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + await expect(page.getByText("Join or create a community")).toBeVisible(); expect(await readSavedRuntime(page)).toBe("codex"); }); +test("Next keeps the draft and retries after a save failure", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + setGlobalAgentConfigErrors: ["Disk is read-only", null], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + + await page.getByTestId("onboarding-finish").click(); + + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + await expect(page.getByTestId("onboarding-config-save-error")).toContainText( + "Disk is read-only", + ); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(1); + + await page.getByTestId("onboarding-finish").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBe("claude"); + expect(await readGlobalConfigSetterCallCount(page)).toBe(2); +}); + test("defaults requires a choice when multiple visible harnesses are ready", async ({ page, }) => { @@ -660,7 +863,7 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy await page.getByTestId("global-agent-default-harness-option-codex").click(); await expect(harness).toHaveText("Codex"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); - await expect.poll(() => readSavedRuntime(page)).toBe("codex"); + expect(await readSavedRuntime(page)).toBeNull(); }); /** diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 345e1ee4d7..830a82879a 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -467,6 +467,8 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Sequenced save failures. A string rejects that call; null succeeds. */ + setGlobalAgentConfigErrors?: (string | null)[]; /** Errors returned by successive backup verification attempts. Null succeeds. */ backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ From d4a4570b9769743899d97480b3bf482860b51d9c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 14:47:52 -0600 Subject: [PATCH 004/134] fix(desktop): clarify inherited agent parallelism (#4010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - show an unambiguous `App default (10)` inherited state for parallelism in create and edit forms - explain that blank inherits the app default and suppress create-form number steppers that could silently set `1` - align the E2E mint fallback with production while preserving explicit input → definition → app-default precedence ## Why The forms displayed `1` even though an untouched field is omitted and desktop minting materializes `10`. The create-form spinner could also turn blank/inherited into an explicit `1` with one click while leaving the field looking nearly unchanged. ## Testing - `pnpm test` (desktop: 3,886 passed) - `pnpm typecheck` (desktop) - `pnpm check` (desktop) - pre-push `desktop-check` and `desktop-test` --------- Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/key_backup_tests.rs | 12 +++++++---- .../agents/lib/agentParallelism.test.mjs | 20 +++++++++++++++++++ .../features/agents/lib/agentParallelism.ts | 18 +++++++++++++++++ .../agents/ui/EditAgentAdvancedFields.tsx | 7 ++++++- .../agents/ui/PersonaAdvancedFields.tsx | 10 +++++++--- desktop/src/testing/e2eBridge.ts | 7 +++++-- 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentParallelism.test.mjs create mode 100644 desktop/src/features/agents/lib/agentParallelism.ts diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 35b486f78d..ff9367641a 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -250,12 +250,16 @@ fn generated_passphrase_respects_word_count_and_separator() { #[test] fn generated_passphrase_clamps_word_count() { + // Use a separator that cannot appear in the EFF wordlist so a generated + // word such as "yo-yo" cannot be mistaken for two words. + const SEPARATOR: &str = "|"; + // Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter. - let phrase = generate_passphrase(1, "-").unwrap(); - assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS); + let phrase = generate_passphrase(1, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).count(), MIN_PASSPHRASE_WORDS); // Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS. - let phrase = generate_passphrase(50, "-").unwrap(); - assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS); + let phrase = generate_passphrase(50, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).count(), MAX_PASSPHRASE_WORDS); } #[test] diff --git a/desktop/src/features/agents/lib/agentParallelism.test.mjs b/desktop/src/features/agents/lib/agentParallelism.test.mjs new file mode 100644 index 0000000000..8d03d087e7 --- /dev/null +++ b/desktop/src/features/agents/lib/agentParallelism.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_AGENT_PARALLELISM, + resolveAgentParallelism, +} from "./agentParallelism.ts"; + +test("parallelism uses the app default only when input and definition omit it", () => { + assert.equal( + resolveAgentParallelism(undefined, undefined), + DEFAULT_AGENT_PARALLELISM, + ); + assert.equal( + resolveAgentParallelism(undefined, null), + DEFAULT_AGENT_PARALLELISM, + ); + assert.equal(resolveAgentParallelism(undefined, 4), 4); + assert.equal(resolveAgentParallelism(2, 4), 2); +}); diff --git a/desktop/src/features/agents/lib/agentParallelism.ts b/desktop/src/features/agents/lib/agentParallelism.ts new file mode 100644 index 0000000000..89544c5bb4 --- /dev/null +++ b/desktop/src/features/agents/lib/agentParallelism.ts @@ -0,0 +1,18 @@ +/** + * Desktop-managed agents materialize this value when neither the create input + * nor the linked definition sets parallelism. Keep in sync with + * `managed_agents::DEFAULT_AGENT_PARALLELISM` in the Tauri backend. + */ +export const DEFAULT_AGENT_PARALLELISM = 10; + +export const AGENT_PARALLELISM_PLACEHOLDER = `App default (${DEFAULT_AGENT_PARALLELISM})`; +export const AGENT_PARALLELISM_HELP = `Leave blank to use the app default (currently ${DEFAULT_AGENT_PARALLELISM}). Custom values may be 1–32.`; +export const EDIT_AGENT_PARALLELISM_HELP = + "Current value for this agent. Custom values may be 1–32."; + +export function resolveAgentParallelism( + input: number | undefined, + definition: number | null | undefined, +): number { + return input ?? definition ?? DEFAULT_AGENT_PARALLELISM; +} diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index c8e21cd6fa..972c4e287e 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -11,6 +11,7 @@ import { import type { AgentPersona } from "@/shared/api/types"; import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism"; export function EditAgentAdvancedFields({ acpCommand, @@ -173,10 +174,14 @@ export function EditAgentAdvancedFields({ id="edit-agent-parallelism" inputMode="numeric" onChange={(event) => onParallelismChange(event.target.value)} - placeholder="1" + placeholder="Current value" + type="text" value={parallelism} /> +

+ {EDIT_AGENT_PARALLELISM_HELP} +

{/* Relay URL: intentionally no editor. The legacy per-record relay pin diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 01485dd9eb..b7d1903784 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -4,6 +4,10 @@ import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { + AGENT_PARALLELISM_HELP, + AGENT_PARALLELISM_PLACEHOLDER, +} from "../lib/agentParallelism"; import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; import { CARD_MINT_KEY_ANNOTATIONS, @@ -83,7 +87,7 @@ export function PersonaAdvancedFields({ >

- How many conversations each running instance handles at once (1–32). + {AGENT_PARALLELISM_HELP}

diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5cbfb03331..895e770f0f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12,6 +12,7 @@ import { import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; +import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; @@ -8075,8 +8076,10 @@ async function handleCreateManagedAgent( args.input.respondTo !== undefined ? (args.input.respondToAllowlist ?? []) : (linkedPersona?.respond_to_allowlist ?? []); - const mintParallelism = - args.input.parallelism ?? linkedPersona?.parallelism ?? 1; + const mintParallelism = resolveAgentParallelism( + args.input.parallelism, + linkedPersona?.parallelism, + ); const personaAvatarUrl = args.input.personaId === undefined ? null From 79815978483ef0ab78f7159c0add3492da6457a1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 14:09:46 -0700 Subject: [PATCH 005/134] fix(reactions): wrap long popover names (#3834) **Category:** fix **User Impact:** Long custom emoji names now stay contained inside reaction popovers and remain fully readable. **Problem:** An unbroken custom emoji name could force a reaction popover beyond its intended maximum width and overflow the message view. **Solution:** Give the reaction popover a definite 288px width and allow the complete emoji name to wrap within it without truncation or ellipsis. Short names retain the same content and interaction behavior.
File changes **desktop/src/features/messages/ui/MessageReactions.tsx** Bounds the reaction popover width and allows long names to break across lines while preserving the full shortcode. **desktop/tests/e2e/reaction-names.spec.ts** Covers fixed width, full text preservation, and wrapping for the maximum supported colon-wrapped reaction name, with deterministic seeded Picsum visual fixtures and explicit image-load waits.
## Reproduction Steps 1. Open a message with a custom emoji reaction whose name is 64 characters. 2. Hover or focus the reaction pill to open its details popover. 3. Confirm the popover remains 288px wide and the complete name wraps within it without ellipsis. 4. Open a short-name reaction and confirm its popover remains readable and unchanged in behavior. ## Screenshots | Before | After | | --- | --- | | ![Maximum-length name before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-before-picsum.png) | ![Maximum-length name after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-after-picsum.png) | **Short-name regression check** ![Short reaction name](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/short-name-after-picsum.png) ## Verification - `pnpm test` in `desktop`: 3,858 passed - Focused reaction-name E2E with seeded Picsum captures: 2 passed - Desktop checks and commit hooks passed Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f` --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageReactions.tsx | 7 +- desktop/tests/e2e/reaction-names.spec.ts | 129 +++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 2250b3931f..cbcb873f5b 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -126,7 +126,10 @@ function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
{userText} reacted with
-
+
{displayName}
@@ -504,7 +507,7 @@ function ReactionPill({ align="start" side="top" sideOffset={6} - className="w-auto min-w-56 max-w-72 rounded-xl p-3" + className="w-72 rounded-xl p-3" onMouseEnter={handleMouseEnter} onMouseLeave={scheduleClose} onOpenAutoFocus={(e) => e.preventDefault()} diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index 512b913dff..028bb1645d 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -1,11 +1,23 @@ import { expect, test } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; const REACTION_TARGET_CONTENT = "React to me with a custom emoji"; const REACTION_TARGET_EVENT_ID = "d".repeat(64); const BOB_PUBKEY = "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; +const MAX_REACTION_NAME = + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl"; +const MAX_REACTION_AVATAR_URL = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%23e5484d"/%3E%3C/svg%3E'; +const SHORT_REACTION_AVATAR_URL = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; +const SCREENSHOT_DIR = + process.env.REACTION_POPOVER_SCREENSHOT_DIR ?? + "test-results/reaction-popover-screenshots"; function reactionTargetRow(page: import("@playwright/test").Page) { return page @@ -14,8 +26,45 @@ function reactionTargetRow(page: import("@playwright/test").Page) { .last(); } +async function waitForImage( + image: import("@playwright/test").Locator, +): Promise { + await expect(image).toBeVisible(); + await expect + .poll(() => + image.evaluate( + (element) => + element instanceof HTMLImageElement && + element.complete && + element.naturalWidth > 0, + ), + ) + .toBe(true); +} + +async function capturePopover( + page: import("@playwright/test").Page, + popover: import("@playwright/test").Locator, + filename: string, +): Promise { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + await waitForAnimations(page); + await popover.screenshot({ + animations: "disabled", + path: path.join(SCREENSHOT_DIR, filename), + }); +} + test.beforeEach(async ({ page }) => { - await installMockBridge(page); + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: BOB_PUBKEY, + displayName: "bob", + avatarUrl: SHORT_REACTION_AVATAR_URL, + }, + ], + }); }); test("reaction popover resolves a reactor with no authored message in the window", async ({ @@ -33,21 +82,89 @@ test("reaction popover resolves a reactor with no authored message in the window ); await page.evaluate( - ({ pubkey, targetId }) => { + ({ pubkey, targetId, avatarUrl }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", - content: "🎉", - extraTags: [["e", targetId]], + content: ":react:", + extraTags: [ + ["e", targetId], + ["emoji", "react", avatarUrl], + ], kind: 7, pubkey, }); }, - { pubkey: BOB_PUBKEY, targetId: REACTION_TARGET_EVENT_ID }, + { + avatarUrl: SHORT_REACTION_AVATAR_URL, + pubkey: BOB_PUBKEY, + targetId: REACTION_TARGET_EVENT_ID, + }, ); const row = reactionTargetRow(page); - const pill = row.getByRole("button", { name: "Toggle 🎉 reaction" }); + const pill = row.getByRole("button", { name: "Toggle :react: reaction" }); await expect(pill).toBeVisible(); await pill.hover(); await expect(page.getByText("bob reacted with")).toBeVisible(); + const popover = page + .locator("[data-radix-popper-content-wrapper]") + .filter({ hasText: "bob reacted with" }); + const avatar = popover.locator("img"); + await expect(avatar).toHaveAttribute("src", SHORT_REACTION_AVATAR_URL); + await waitForImage(avatar); + await capturePopover(page, popover, "short-name-after.png"); +}); + +test("maximum-length reaction name wraps inside a fixed-width popover", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + }) === true, + ); + + const reaction = `:${MAX_REACTION_NAME}:`; + await page.evaluate( + ({ content, targetId, avatarUrl }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + extraTags: [ + ["e", targetId], + ["emoji", content.slice(1, -1), avatarUrl], + ], + kind: 7, + }); + }, + { + avatarUrl: MAX_REACTION_AVATAR_URL, + content: reaction, + targetId: REACTION_TARGET_EVENT_ID, + }, + ); + + const pill = reactionTargetRow(page).getByRole("button", { + name: `Toggle ${reaction} reaction`, + }); + await expect(pill).toBeVisible(); + await pill.focus(); + + const popover = page + .locator("[data-radix-popper-content-wrapper]") + .filter({ hasText: reaction }); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("width", "288px"); + const reactionName = popover.getByTestId("reaction-popover-name"); + await expect(reactionName).toHaveCSS("word-break", "break-all"); + await expect(reactionName).toHaveText(reaction); + const avatar = popover.locator("img"); + await expect(avatar).toHaveAttribute("src", MAX_REACTION_AVATAR_URL); + await waitForImage(avatar); + await capturePopover(page, popover, "max-length-after.png"); }); From 027a74a61c8643a1d1086d3e8307fad89d7735f7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 3 Aug 2026 22:12:21 +0100 Subject: [PATCH 006/134] Polish Share Compute settings (#3735) ## Summary - Refresh Share Compute with the shared agent-style model controls. - Reveal sharing details and advanced options only while sharing. - Remove the preview-only mesh API path. ## Validation - `pnpm check` - `pnpm test` - `pnpm exec playwright test tests/e2e/mesh-compute.spec.ts` Snapshots are attached in a follow-up comment. --------- Signed-off-by: kenny lopez --- .../ui/MeshComputeSettingsCard.tsx | 511 ++++++++++-------- desktop/src/testing/e2eBridge.ts | 47 +- .../global-agent-config-screenshots.spec.ts | 1 + desktop/tests/e2e/mesh-compute.spec.ts | 63 ++- 4 files changed, 364 insertions(+), 258 deletions(-) diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index fd7550eff0..bf2d8e7c22 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -1,9 +1,19 @@ import * as React from "react"; -import { ChevronDown, Cpu } from "lucide-react"; +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { cn } from "@/shared/lib/cn"; +import { + AgentConfigTextInput, + AgentDropdownSelect, + type AgentDropdownOption, +} from "@/features/agents/ui/agentConfigControls"; +import { + CUSTOM_MODEL_DROPDOWN_VALUE, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "@/features/agents/ui/agentConfigOptions"; import { meshStartNode, @@ -17,10 +27,6 @@ import type { MeshModelOption, MeshNodeStatus, } from "@/shared/api/tauriMesh"; -import { - SettingsOptionGroup, - SettingsOptionRow, -} from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { classifyModelRef } from "../classifyModelRef"; import { @@ -36,6 +42,20 @@ import { deriveServingIndicator } from "../servingUsage"; const MODEL_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.model.v1"; const MAX_VRAM_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.max-vram-gb.v1"; +// Keep the Share compute controls visually and behaviorally aligned with the +// agent configuration fields. This is intentionally the same shell used by +// AgentDefaultsEditor rather than a local approximation of a select. +const MESH_SELECT_TRIGGER_CLASS = cn( + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", +); + +const SHARE_COMPUTE_REVEAL_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + function readDraft(key: string): string { try { return window.localStorage.getItem(key) ?? ""; @@ -64,6 +84,7 @@ function writeDraft(key: string, value: string): void { * exposing implementation protocols or raw mesh controls. */ export function MeshComputeSettingsCard() { + const shouldReduceMotion = useReducedMotion(); const { status, error, refresh } = useMeshNodeStatus(); const [installedModels, setInstalledModels] = React.useState< MeshModelOption[] @@ -75,6 +96,7 @@ export function MeshComputeSettingsCard() { const [maxVramGb, setMaxVramGb] = React.useState(() => readDraft(MAX_VRAM_DRAFT_STORAGE_KEY), ); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [actionInFlight, setActionInFlight] = React.useState(false); const [pendingAction, setPendingAction] = React.useState< @@ -163,6 +185,7 @@ export function MeshComputeSettingsCard() { const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming); const refClass = classifyModelRef(modelInput); const canStart = refClass.kind !== "unknown" && !actionInFlight; + const showSharingControls = isSharing || pendingAction === "start"; async function handleToggle(next: boolean) { // Never let the Share switch tear down a consume session. The switch is @@ -204,12 +227,7 @@ export function MeshComputeSettingsCard() {
- Share this machine with your relay. When on, other members can run - their agents here. - - } + description="Share this machine with members of this relay so they can run agents here." /> {error ? ( @@ -226,8 +244,8 @@ export function MeshComputeSettingsCard() { ) : null} - - +
+
- - {servingIndicator.show ? ( -

- {servingIndicator.label} - {servingIndicator.detail ? ( - - {" "} - · {servingIndicator.detail} - - ) : null} -

+ {!isSharing ? ( + ) : null}
- +
+ + { + setModelInput(next); + writeDraft(MODEL_DRAFT_STORAGE_KEY, next); + }} + /> -
-
- ) : null} -
+ ) : null} + + ) : null} -
- setAdvancedOpen((e.target as HTMLDetailsElement).open) - } - open={advancedOpen} - > - - - Advanced - -
- - { - const next = e.target.value; - setMaxVramGb(next); - writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next); - }} - placeholder="No limit" - value={maxVramGb} - /> - {status?.consoleUrl ? ( -

- Debug console:{" "} - - {status.consoleUrl} - -

- ) : null} -
-
-
- -

- Only members of this relay can use this machine's shared compute. -

+ + {showSharingControls ? ( + +
+

Sharing

+
+ + {servingIndicator.show ? ( +

+ {servingIndicator.label} + {servingIndicator.detail ? ( + + {" "} + · {servingIndicator.detail} + + ) : null} +

+ ) : null} +
+
+
+ ) : null} +
+
); } @@ -465,100 +465,159 @@ const FIT_CLASS: Record = { }; /** - * Hardware-ranked curated model list (mesh-console's diagnose pattern). - * Click a row to fill the model field. Models too large for this machine are - * listed but disabled — honest about why, instead of hiding them. + * Share Compute's models use the agent configuration picker instead of a + * second, hand-rolled option system. The catalog remains hardware-aware, but + * its recommendations, installed models, and advanced choices now appear in + * the same searchable dropdown used when customizing an agent. */ -function CatalogPicker({ +function MeshModelPicker({ catalog, disabled, - onPick, - selected, + installedModels, + isCustomModelEditing, + model, + onCustomModelEditingChange, + onModelChange, }: { - catalog: MeshModelCatalog; + catalog: MeshModelCatalog | null; disabled: boolean; - onPick: (name: string) => void; - selected: string; + installedModels: readonly MeshModelOption[]; + isCustomModelEditing: boolean; + model: string; + onCustomModelEditingChange: (editing: boolean) => void; + onModelChange: (model: string) => void; }) { - const [expanded, setExpanded] = React.useState(false); - // Above the fold: the Buzz-curated picks (models known to work well with - // agents on shared compute). Below: everything else, as advanced options. - const curated = catalog.entries.filter((e) => e.curated); - const advanced = catalog.entries.filter((e) => !e.curated); - const visible = expanded ? catalog.entries : curated; + const options = React.useMemo(() => { + const seen = new Set(); + const catalogOptions = (catalog?.entries ?? []).map((entry) => { + seen.add(entry.name); + return { + disabled: entry.fit === "too_large", + label: , + value: entry.name, + }; + }); + const localOptions = installedModels.flatMap((installed) => { + if (seen.has(installed.id)) return []; + return [ + { + label: ( +
+ + {installed.name ?? installed.id} + + + Installed + +
+ ), + value: installed.id, + }, + ]; + }); + return [ + ...catalogOptions, + ...localOptions, + { label: "Custom model…", value: CUSTOM_MODEL_DROPDOWN_VALUE }, + ]; + }, [catalog?.entries, installedModels]); + const knownModel = options.some((option) => option.value === model.trim()); + const showCustomModelInput = + isCustomModelEditing || (model.trim().length > 0 && !knownModel); + const selectedValue = showCustomModelInput + ? CUSTOM_MODEL_DROPDOWN_VALUE + : model.trim(); + + function handleModelChange(next: string) { + if (next === CUSTOM_MODEL_DROPDOWN_VALUE) { + onCustomModelEditingChange(true); + return; + } + onCustomModelEditingChange(false); + onModelChange(next); + } + return ( -
+
+ + + {showCustomModelInput ? ( + { + // A stored custom ref starts out inferred rather than explicitly + // selected. Mark it as an active custom edit before applying a + // cleared value so the field stays mounted while it is replaced. + onCustomModelEditingChange(true); + onModelChange(event.target.value); + }} + placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main" + usePersonaInputStyle + value={model} + /> + ) : null}

- Recommended for this machine - {catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("} - {catalog.vramDisplay} AI memory): + {catalog + ? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.` + : "Choose a model or enter a model reference or local file."}{" "} + Buzz downloads remote models when sharing starts.

-
    - {visible.map((entry) => { - const isSelected = entry.name === selected; - const tooLarge = entry.fit === "too_large"; - return ( -
  • - -
  • - ); - })} -
- {advanced.length > 0 ? ( - +
+ ); +} + +function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) { + return ( +
+ {entry.name} + {entry.size} + + {FIT_LABEL[entry.fit]} + + {entry.recommended ? ( + + Recommended + + ) : null} + {entry.installed ? ( + + Installed + + ) : null} + {!entry.curated ? ( + + Advanced + ) : null}
); } function StatusLine({ + displayModel, isConsuming, + omitSharingVerb = false, pendingAction, status, }: { + displayModel?: string; isConsuming: boolean; + omitSharingVerb?: boolean; pendingAction: "start" | "stop" | null; status: MeshNodeStatus | null; }) { @@ -583,12 +642,10 @@ function StatusLine({ return

Checking status…

; } const { state, health, modelId, modelName } = status; - const modelLabel = modelName ?? modelId ?? ""; + const modelLabel = displayModel ?? modelName ?? modelId ?? ""; if (state === "off") { - return ( -

Not sharing right now.

- ); + return null; } if (state === "starting") { const reason = @@ -614,7 +671,9 @@ function StatusLine({ } return (

- Sharing{modelLabel ? ` ${modelLabel}` : ""} with relay members. + {omitSharingVerb ? "" : "Sharing"} + {modelLabel ? `${omitSharingVerb ? "" : " "}${modelLabel}` : ""} with + relay members.

); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 895e770f0f..6520dd760d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2950,6 +2950,7 @@ const ZERO_SERVING_USAGE: MockServingUsage = { const mockMeshState: { admitted: boolean; models: Array<{ id: string; name: string | null }>; + activeModel: { id: string; name: string | null } | null; denyReason: string; nodeState: "off" | "running"; nodeMode: "serve" | "client" | null; @@ -2957,6 +2958,7 @@ const mockMeshState: { } = { admitted: true, models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], + activeModel: null, denyReason: "not a relay member", nodeState: "off", nodeMode: null, @@ -2966,6 +2968,7 @@ const mockMeshState: { function resetMockMesh() { mockMeshState.admitted = true; mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }]; + mockMeshState.activeModel = null; mockMeshState.denyReason = "not a relay member"; mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; @@ -9908,22 +9911,32 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => { injectObserverEventsForE2E(agentPubkey, events); }; + const meshModelName = (modelId: string) => { + const basename = modelId.split("/").at(-1) ?? modelId; + return basename + .replace(/-(?:Instruct|GGUF)(?:-|:|$).*/, "") + .replace(/:.*/, "") + .replaceAll("-", " "); + }; const meshNodeStatus = ( state: "off" | "running", mode: "serve" | "client" | null, - ) => ({ - state, - mode, - health: { status: "ok" as const, reason: null }, - apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null, - consoleUrl: null, - modelId: mockMeshState.models[0]?.id ?? null, - modelName: mockMeshState.models[0]?.name ?? null, - inviteToken: state === "running" ? "mock-endpoint-addr" : null, - endpointId: state === "running" ? "mock-endpoint-id" : null, - deviceId: state === "running" ? "mock-endpoint-id" : null, - deviceName: state === "running" ? "Mock desktop" : null, - }); + ) => { + const model = mockMeshState.activeModel ?? mockMeshState.models[0] ?? null; + return { + state, + mode, + health: { status: "ok" as const, reason: null }, + apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null, + consoleUrl: null, + modelId: model?.id ?? null, + modelName: model?.name ?? null, + inviteToken: state === "running" ? "mock-endpoint-addr" : null, + endpointId: state === "running" ? "mock-endpoint-id" : null, + deviceId: state === "running" ? "mock-endpoint-id" : null, + deviceName: state === "running" ? "Mock desktop" : null, + }; + }; let mockImportedVoices: Array<{ key: string; displayName: string; @@ -10300,10 +10313,15 @@ export function maybeInstallE2eTauriMocks() { return mockMeshState.servingUsage; case "mesh_start_node": { const req = ( - payload as { request?: { mode?: "serve" | "client" } } | null + payload as { + request?: { mode?: "serve" | "client"; modelId?: string }; + } | null )?.request; mockMeshState.nodeState = "running"; mockMeshState.nodeMode = req?.mode ?? "serve"; + mockMeshState.activeModel = req?.modelId + ? { id: req.modelId, name: meshModelName(req.modelId) } + : (mockMeshState.models[0] ?? null); return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); } case "mesh_stop_node": @@ -10317,6 +10335,7 @@ export function maybeInstallE2eTauriMocks() { } mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; + mockMeshState.activeModel = null; return meshNodeStatus("off", null); case "get_identity": { const isLost = diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 4c72b72f75..451989fb7f 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -226,6 +226,7 @@ test.describe("global agent config screenshots", () => { await page .getByTestId("global-agent-default-harness-option-claude") .click(); + await waitForAnimations(page); await expect(page.getByTestId("global-agent-model")).toHaveText( /Default model/, ); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index b2e8fc28a9..c55b9db5d9 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,9 +15,8 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute selects the curated default and starts and stops sharing", async ({ - page, -}) => { +test("Share compute chooses a model before sharing", async ({ page }) => { + const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"; await installMockBridge(page); await page.goto("/"); await openSettings(page, "compute"); @@ -26,19 +25,34 @@ test("Share compute selects the curated default and starts and stops sharing", a const toggle = page.getByTestId("mesh-share-compute-toggle"); const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText("Not sharing right now"); - await expect(card).toContainText( - "Choose a suggested model below, or enter a model reference or local file", - ); - await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect(toggle).toBeEnabled(); + await model.click(); + await page.getByRole("option", { name: "Custom model…" }).click(); + await page.getByLabel("Custom model reference").fill(modelRef); + + await toggle.click(); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toBeVisible(); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toBeVisible(); + await expect(model).toBeVisible(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - - await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toContainText("SmolLM2 135M with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -53,13 +67,20 @@ test("Share compute selects the curated default and starts and stops sharing", a .toContainEqual({ command: "mesh_start_node", payload: { - request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + request: { mode: "serve", modelId: modelRef }, }, }); await toggle.click(); await expect(toggle).not.toBeChecked(); - await expect(card).toContainText("Not sharing right now"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -97,16 +118,20 @@ test("a consuming client can switch to sharing its saved local model", async ({ const card = page.getByTestId("settings-mesh-share-compute"); const toggle = page.getByTestId("mesh-share-compute-toggle"); - const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText( "This machine is currently using another member's shared compute", ); await expect(card).toContainText("Buzz may briefly restart"); await expect(toggle).not.toBeChecked(); - await expect(model).toBeEnabled(); - await expect(model).toHaveValue(localModel); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); await expect(toggle).toBeEnabled(); + const customModel = page.getByLabel("Custom model reference"); + await expect(customModel).toHaveValue(localModel); + await customModel.fill(""); + await expect(customModel).toBeVisible(); + await customModel.fill("hf://demo/replacement-model:Q4_K_M"); await toggle.click(); await expect(toggle).toBeChecked(); @@ -117,6 +142,8 @@ test("a consuming client can switch to sharing its saved local model", async ({ expect(commands.names).not.toContain("mesh_stop_node"); expect(commands.payloads).toContainEqual({ command: "mesh_start_node", - payload: { request: { mode: "serve", modelId: localModel } }, + payload: { + request: { mode: "serve", modelId: "hf://demo/replacement-model:Q4_K_M" }, + }, }); }); From 985cdcc6eac33ccd77bc50c26e22c701d07eda4e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 18:09:24 -0400 Subject: [PATCH 007/134] feat(agents): model-tuning parity in global Agent Defaults editor (#4578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview The global Agent Defaults surface (Settings card, defaults modal, onboarding) exposed structured controls for Effort but left Max Output Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs had structured numeric fields but only for `isBuzzAgentRuntime` — incorrectly excluding Goose. This PR unifies numeric-tuning capability across all surfaces, fixes a pre-existing dual-editor defect, and adds full test coverage. ## What changed ### Phase 1 — Catalog projection - Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs` (`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere). - Project all three numeric env-var fields (`max_tokens_env_var`, `context_limit_env_var`, `max_rounds_env_var`) end-to-end: `AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`, `RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in `tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`). ### Phase 2 — Field model - `deriveAgentConfigFieldModel` now derives `maxOutputTokens` / `contextLimit` / `maxRounds` descriptors from catalog-projected fields. - `structuredEnvKeys(descriptors)` — exported helper that takes the **rendered** descriptor set (not the whole model). Hidden keys follow what is actually rendered per surface: global hides effort + all three numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides effort + three numeric keys; per-agent Goose hides only its two numeric keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row per-agent because no effort control renders there. ### Phase 3 — UI - Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as a shared descriptor-driven component (`descriptors`, `envVars`, `inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima: `NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1, `maxRounds`: 0) applied to ``. - **Global surface** (`AgentConfigFields.tsx`): deduplicate the previously duplicated Advanced env-editor block; render `NumericTuningFields` below the env editor when descriptors exist; `hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys` so structured keys are never double-rendered. Under 1000 lines. - **Per-agent surfaces** (`EditAgentAdvancedFields`, `PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from `agentConfigCore`; hidden keys come from `structuredEnvKeys(numericDescriptors)` — the same rendered descriptor set, no local rebuilding (fixes pre-existing dual-editor defect). Catalog status carried as `RuntimeCatalogStatus` (`loading | ready | error`); both error and loading withhold structured controls and leave saved values visible as generic rows, making error distinguishable from "runtime not capable" (`ready` + no runtime). - **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`, callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?: "loading" | "ready" | "error"` (replaces separate `runtimesLoading`/`runtimesError` booleans); all call sites — `AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`, `UserProfilePersonaDialogs` — compute and pass the status. ### Phase 4 — Tests - `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows, value, requiredKeys, hiddenKeys) => Record` helper for isolation testing. - **17 new node tests** in `agentConfigCore.test.mjs`: `deriveNumericDescriptors` (all three fields, partial, undefined runtime, matches field-model subset); `structuredEnvKeys` per surface including discriminating Goose per-agent effort-key invariant; `NUMERIC_KIND_MIN` values. - **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key preserved through generic row edits; runtime-switch then generic edit (derives both descriptor sets, asserts new-runtime hidden key survives `buildRecord` via `hiddenKeys` and old-runtime key survives via generic rows); baked numeric key excluded via `filterBakedGenericRows` with `numericTuningPlaceholder` assertion; clearing a structured override — `numericTuningPlaceholder` verifies placeholder text. - **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to smoke project `testMatch`): global numeric fields visible for buzz-agent; global: non-capable runtime hides numeric controls; Goose per-agent shows `Inherit (16384)` after saving global value through the UI; delayed catalog: saved values visible as generic rows while loading then structured controls appear after settle; failed catalog: saved values remain visible as generic rows (never the "unsupported" empty state). ## Result - buzz-agent global defaults: Max output tokens, Context limit, Max rounds as structured inputs with `Inherit (N)` placeholders from baked env. - Goose global defaults: Max output tokens, Context limit as structured inputs. - A Goose global value surfaces as `Inherit ()` in the per-agent Goose edit dialog. - No structured key is editable in two places on any surface; no persisted key has zero editors. - No `runtime.id === "buzz-agent"` comparison decides numeric-field visibility anywhere — capability flows catalog → `AcpRuntimeCatalogEntry` → field model → UI. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/playwright.config.ts | 1 + .../src-tauri/src/commands/agent_config.rs | 3 +- .../src/commands/agent_config_tests.rs | 1 + .../src-tauri/src/commands/agent_discovery.rs | 27 +- .../config_bridge/reader_tests.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 39 +- .../src/managed_agents/discovery/presets.rs | 3 + .../discovery/runtime_metadata.rs | 2 + .../src-tauri/src/managed_agents/readiness.rs | 12 +- desktop/src-tauri/src/managed_agents/types.rs | 12 +- .../agents/lib/agentConfigCore.test.mjs | 411 +++++++++++++++++- .../features/agents/lib/agentConfigCore.ts | 157 +++++++ .../features/agents/ui/AgentConfigFields.tsx | 126 +++--- .../agents/ui/AgentDefinitionDialog.tsx | 13 +- .../src/features/agents/ui/AgentDialog.tsx | 8 +- .../agents/ui/AgentInstanceEditDialog.tsx | 38 +- .../agents/ui/AgentManagementDialogs.tsx | 4 +- desktop/src/features/agents/ui/AgentsView.tsx | 16 +- .../agents/ui/EditAgentAdvancedFields.tsx | 85 +++- .../features/agents/ui/EnvVarsEditor.test.mjs | 308 +++++++++++++ .../src/features/agents/ui/EnvVarsEditor.tsx | 39 +- .../agents/ui/PersonaAdvancedFields.tsx | 79 +++- .../agents/ui/RequestedAgentCreateDialogs.tsx | 8 +- .../agents/ui/buzzAgentModelTuningFields.tsx | 196 ++++----- .../src/features/agents/useAgentManagement.ts | 6 +- .../features/profile/ui/UserProfilePanel.tsx | 1 + .../profile/ui/UserProfilePersonaDialogs.tsx | 9 +- desktop/src/shared/api/tauri.ts | 26 +- desktop/src/shared/api/types.ts | 10 +- desktop/src/testing/e2eBridge.ts | 56 ++- .../tests/e2e/agent-numeric-tuning.spec.ts | 372 ++++++++++++++++ desktop/tests/helpers/bridge.ts | 21 +- 32 files changed, 1769 insertions(+), 322 deletions(-) create mode 100644 desktop/tests/e2e/agent-numeric-tuning.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 796fdede1e..f5c1e34a52 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -136,6 +136,7 @@ export default defineConfig({ "**/inline-custom-harness.spec.ts", "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", + "**/agent-numeric-tuning.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 7aded79599..12e6983eea 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -31,8 +31,7 @@ pub struct RuntimeFileConfigSubset { pub provider: Option, /// Model set in the harness config file, if any. pub model: Option, - /// Flat credential env keys found in the harness config file's `extra` map - /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + /// Flat credential env keys in the harness config file's `extra` map (e.g. `DATABRICKS_HOST`); only non-empty values included. pub satisfied_env_keys: Vec, } diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index f3667cff45..5519153578 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -57,6 +57,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 4abf53ee91..0eb024a86a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -21,25 +21,13 @@ fn active_installs() -> &'static std::sync::Mutex( runtime_id: &str, adapter_path: Option<&std::path::Path>, @@ -177,6 +165,9 @@ pub async fn save_custom_harness( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: definition.install_hint, install_instructions_url: definition.install_instructions_url, can_auto_install: false, 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 153db1bbd8..62caffeb2e 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 @@ -56,6 +56,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -644,6 +645,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 248625ce93..2cccccb95e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -103,6 +103,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -135,6 +136,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), @@ -167,6 +169,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run `codex login` to authenticate."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. @@ -200,6 +203,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -278,11 +282,8 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id -/// if the catalog entry is missing. -/// -/// The previous default was the bare global `goose`, which is not on PATH on a -/// stock Windows install: every worker failed with `program not found`. The -/// bundled `buzz-agent` ships with the app and resolves on every platform. +/// if the catalog entry is missing. (Previous default was bare `goose`, which +/// is not on PATH on a stock Windows install; buzz-agent ships with the app.) pub fn default_agent_command() -> String { known_acp_runtime_exact("buzz-agent") .and_then(|p| p.commands.first().copied()) @@ -375,10 +376,8 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. -/// -/// This sentinel is an internal Rust contract: user-facing surfaces must -/// convert it to a sentence via [`user_facing_harness_error`] (spawn) or to -/// the missing id via [`dangling_harness_id`] (summary) — never show it raw. +/// Internal Rust contract: surfaces must convert it via [`user_facing_harness_error`] or +/// [`dangling_harness_id`] — never show it raw. pub(crate) const DANGLING_HARNESS_PREFIX: &str = "DANGLING_HARNESS_ID:"; /// Extract the missing harness id from a `DANGLING_HARNESS_ID:` error. @@ -398,22 +397,16 @@ pub(crate) fn user_facing_harness_error(error: &str) -> String { } } -/// Summary-row display for a dangling harness id: shows the *missing* id so -/// the agent list tells the same story as spawn (which refuses with the -/// sentence above), rather than silently falling back to the default command -/// as if the agent were healthy. +/// Summary-row display for a dangling harness id: shows the *missing* id so the agent list +/// tells the same story as spawn rather than silently falling back to the default command. pub(crate) fn dangling_harness_display(id: &str) -> String { format!("harness (deleted): {id}") } /// Spawn-time variant of `record_agent_command` that returns a typed error when -/// a record's `runtime` id or its persona's `runtime` id is set but cannot be -/// resolved (i.e. the definition was deleted after the agent was created). -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` so callers can surface the error -/// without falling through to `buzz-agent`. When there is no runtime id at all -/// the fallback to `default_agent_command()` is intentional (legacy agents -/// pre-date the unified harness model). +/// a record's `runtime` id or persona's `runtime` id is set but unresolvable +/// (definition deleted after agent was created). Returns `Err("DANGLING_HARNESS_ID:")`. +/// When there is no runtime id at all, falls through to `default_agent_command()` intentionally. pub fn try_record_agent_command( record: &crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], @@ -1413,6 +1406,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr model_env_var: runtime.model_env_var.map(str::to_string), provider_env_var: runtime.provider_env_var.map(str::to_string), thinking_env_var: runtime.thinking_env_var.map(str::to_string), + max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string), + context_limit_env_var: runtime.context_limit_env_var.map(str::to_string), + max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string), install_hint, install_instructions_url: install_instructions_url.to_string(), can_auto_install, @@ -1571,6 +1567,9 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.clone(), install_instructions_url: def.install_instructions_url.clone(), // Security line: custom definitions carry no install scripts. diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 3622b21c4a..bcc4288005 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,9 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.to_string(), install_instructions_url: def.install_instructions_url.to_string(), can_auto_install: false, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index fdfe9b8be7..34edecdcd9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -52,6 +52,8 @@ pub(crate) struct KnownAcpRuntime { pub max_tokens_env_var: Option<&'static str>, /// Env var for normalizing `context_limit`. `None` when not applicable. pub context_limit_env_var: Option<&'static str>, + /// Env var for normalizing `max_rounds`. `None` when not applicable. + pub max_rounds_env_var: Option<&'static str>, /// Normalized field keys that must be set for this harness to function. /// Used by the config bridge to mark fields as required in the UI. /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..26902ae8de 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1051,19 +1051,16 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, } } - /// Returns the absolute path of the currently-running test binary as a - /// `&'static str`. Host-portable stand-in for a "present" binary: - /// the path is absolute so `find_command` resolves it via `path.exists()` - /// rather than searching `PATH`, and the file always exists on the host. - /// - /// The tiny allocation is intentionally leaked — this runs at most once per - /// test process and the process exits immediately after tests complete. + /// Returns the absolute path of the currently-running test binary as a `&'static str`. + /// Host-portable stand-in for a "present" binary: absolute path so `find_command` resolves + /// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. fn present_binary_str() -> &'static str { let path = std::env::current_exe().expect("current_exe must be available in tests"); Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) @@ -1246,6 +1243,7 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..255c1aae32 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -594,10 +594,8 @@ pub enum AcpAvailabilityStatus { NotInstalled, } -/// Authentication/login status for a CLI-based ACP runtime. -/// -/// Serializes as a tagged union `{ status: "...", diagnostic?: "..." }` so -/// the TypeScript side can exhaustively switch on `status`. +/// Authentication/login status for a CLI-based ACP runtime. Serializes as a tagged union +/// `{ status: "...", diagnostic?: "..." }` so the TypeScript side can exhaustively switch on `status`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "status")] pub enum AuthStatus { @@ -616,8 +614,7 @@ pub enum AuthStatus { Unknown, } -/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string -/// so the TypeScript consumer can switch on it without numeric comparisons. +/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string so the TypeScript consumer can switch on it without numeric comparisons. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HarnessSource { @@ -645,6 +642,9 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, pub install_hint: String, pub install_instructions_url: String, /// true when at least one automated install step is available diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 62d8a61a6f..92159ff275 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts"; +import { + deriveAgentConfigFieldModel, + deriveNumericDescriptors, + structuredEnvKeys, +} from "./agentConfigCore.ts"; +import { NUMERIC_KIND_MIN } from "../ui/buzzAgentModelTuningFields.tsx"; const config = { env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" }, @@ -23,6 +28,9 @@ function runtime(id, metadata = {}) { modelEnvVar: null, providerEnvVar: null, thinkingEnvVar: null, + maxTokensEnvVar: null, + contextLimitEnvVar: null, + maxRoundsEnvVar: null, installHint: "", installInstructionsUrl: "", canAutoInstall: false, @@ -152,3 +160,404 @@ test("catalog mismatch cleanup is named and restricted to onboarding", () => { onCatalogMismatch: "explainOnly", }); }); + +// ── Numeric descriptor derivation per runtime ───────────────────────────── +// +// The catalog-projected fields (maxTokensEnvVar, contextLimitEnvVar, +// maxRoundsEnvVar) determine which numeric descriptors appear in the field +// model. Capability facts flow catalog → descriptor → UI; no runtime-ID +// comparison decides numeric-field visibility. + +test("buzz-agent derives three numeric descriptors from catalog fields", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + const numericKinds = model.fields + .filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ) + .map((f) => f.kind); + assert.deepEqual(numericKinds, [ + "maxOutputTokens", + "contextLimit", + "maxRounds", + ]); + + const maxOutput = field(model, "maxOutputTokens"); + assert.equal(maxOutput.render, "control"); + assert.deepEqual(maxOutput.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + }); + assert.deepEqual(maxOutput.targetApplication, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + }); + + const ctx = field(model, "contextLimit"); + assert.deepEqual(ctx.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + }); + + const rounds = field(model, "maxRounds"); + assert.deepEqual(rounds.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_ROUNDS", + }); +}); + +test("Goose derives two numeric descriptors and no maxRounds", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { + modelEnvVar: "GOOSE_MODEL", + providerEnvVar: "GOOSE_PROVIDER", + thinkingEnvVar: "GOOSE_THINKING_EFFORT", + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, // Goose has no max-rounds env var + }), + scope: "global", + }); + + const numericKinds = model.fields + .filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ) + .map((f) => f.kind); + assert.deepEqual(numericKinds, ["maxOutputTokens", "contextLimit"]); + assert.equal( + field(model, "maxRounds"), + undefined, + "maxRounds must be absent for Goose", + ); + + assert.deepEqual(field(model, "maxOutputTokens").currentPersistence, { + kind: "envVar", + key: "GOOSE_MAX_TOKENS", + }); + assert.deepEqual(field(model, "contextLimit").currentPersistence, { + kind: "envVar", + key: "GOOSE_CONTEXT_LIMIT", + }); +}); + +test("Claude derives no numeric descriptors", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("claude"), + scope: "global", + }); + + const hasNumeric = model.fields.some((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + assert.equal(hasNumeric, false, "Claude must have no numeric descriptors"); +}); + +test("Codex derives no numeric descriptors", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("codex"), + scope: "global", + }); + + const hasNumeric = model.fields.some((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + assert.equal(hasNumeric, false, "Codex must have no numeric descriptors"); +}); + +test("numeric descriptor value is read from env_vars when set", () => { + const cfgWithTuning = { + env_vars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192", + BUZZ_AGENT_MAX_CONTEXT_TOKENS: "100000", + BUZZ_AGENT_MAX_ROUNDS: "25", + }, + model: "test-model", + preferred_runtime: null, + provider: "anthropic", + }; + const model = deriveAgentConfigFieldModel({ + config: cfgWithTuning, + runtime: runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + assert.equal(field(model, "maxOutputTokens").value, "8192"); + assert.equal(field(model, "contextLimit").value, "100000"); + assert.equal(field(model, "maxRounds").value, "25"); +}); + +test("numeric descriptor value is null when env var is absent", () => { + const cfgEmpty = { + env_vars: {}, + model: "test-model", + preferred_runtime: null, + provider: null, + }; + const model = deriveAgentConfigFieldModel({ + config: cfgEmpty, + runtime: runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + assert.equal(field(model, "maxOutputTokens").value, null); + assert.equal(field(model, "contextLimit").value, null); + assert.equal(field(model, "maxRounds").value, null); +}); + +// ── structuredEnvKeys: rendered-descriptor ownership ───────────────────── +// +// structuredEnvKeys accepts the descriptors a surface ACTUALLY renders and +// returns the env-var keys that surface owns. Keys only appear in the output +// when a first-class control for them renders — a persisted value must never +// have zero editors. +// +// Critical invariant: per-agent Goose passes only its two numeric descriptors +// (no effort descriptor, because no effort control renders there). The effort +// key (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — it must +// stay a visible generic env row where any saved value can be edited. + +test("structuredEnvKeys_global_includes_effort_key_and_numeric_keys", () => { + // Global surface renders effort + all numeric descriptors. + const buzzAgentModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + // Global renders all renderable descriptors. + const renderedDescriptors = buzzAgentModel.fields.filter( + (f) => f.render === "control", + ); + const keys = structuredEnvKeys(renderedDescriptors); + + assert.ok( + keys.includes("BUZZ_AGENT_THINKING_EFFORT"), + "effort key must be hidden on global (effort control renders)", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + "maxOutputTokens key must be hidden on global", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + "contextLimit key must be hidden on global", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_ROUNDS"), + "maxRounds key must be hidden on global", + ); +}); + +test("structuredEnvKeys_per_agent_buzz_agent_includes_effort_and_numeric_keys", () => { + // Per-agent buzz-agent renders effort + all 3 numeric descriptors. + const buzzAgentModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "definition", + }); + + const renderedDescriptors = buzzAgentModel.fields.filter( + (f) => f.render === "control", + ); + const keys = structuredEnvKeys(renderedDescriptors); + + assert.ok(keys.includes("BUZZ_AGENT_THINKING_EFFORT"), "effort key present"); + assert.ok(keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), "maxTokens present"); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + "contextLimit present", + ); + assert.ok(keys.includes("BUZZ_AGENT_MAX_ROUNDS"), "maxRounds present"); +}); + +test("structuredEnvKeys_per_agent_goose_excludes_effort_key_discriminating_invariant", () => { + // Per-agent Goose: effort migration is out of scope, so no effort control + // renders on the per-agent surface for Goose. Only the 2 numeric descriptors + // are passed as the rendered set. The effort persistence key + // (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — any saved + // value must remain visible and editable as a generic env row. + const gooseModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { + thinkingEnvVar: "GOOSE_THINKING_EFFORT", + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + }), + scope: "definition", + }); + + // Simulate per-agent surface: only the numeric descriptors render (no effort + // control for Goose per-agent — effort migration is out of scope). + const numericDescriptorsOnly = gooseModel.fields.filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + + const keys = structuredEnvKeys(numericDescriptorsOnly); + + assert.equal( + keys.includes("BUZZ_AGENT_THINKING_EFFORT"), + false, + "effort persistence key must NOT be hidden for Goose per-agent — no editor would replace it", + ); + assert.ok( + keys.includes("GOOSE_MAX_TOKENS"), + "maxTokens key must be present (control renders)", + ); + assert.ok( + keys.includes("GOOSE_CONTEXT_LIMIT"), + "contextLimit key must be present (control renders)", + ); +}); + +test("structuredEnvKeys_deferred_effort_excluded_from_result", () => { + // A deferred effort descriptor (render !== "control") must not contribute + // its key to the hidden set — the value has no editor on this surface. + const claudeModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("claude"), + scope: "global", + }); + + const allDescriptors = claudeModel.fields; // includes deferred effort + const keys = structuredEnvKeys(allDescriptors); + + // Claude's deferred effort has currentPersistence.kind === "unavailable" + // and render === "deferredUntilNativeOptionsAvailable"; no key emitted. + assert.equal( + keys.length, + 0, + "deferred effort and model descriptors must not contribute hidden keys", + ); +}); + +// ── deriveNumericDescriptors: standalone helper ─────────────────────────── +// +// The same logic that populates the numeric portion of deriveAgentConfigFieldModel +// is available as a standalone helper for per-agent surfaces that don't need +// the full field model. + +test("deriveNumericDescriptors_undefined_runtime_returns_empty", () => { + const ds = deriveNumericDescriptors(undefined); + assert.deepEqual(ds, []); +}); + +test("deriveNumericDescriptors_runtime_with_all_three_fields", () => { + const ds = deriveNumericDescriptors( + runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + ); + assert.deepEqual( + ds.map((d) => d.kind), + ["maxOutputTokens", "contextLimit", "maxRounds"], + ); + for (const d of ds) { + assert.equal(d.render, "control"); + assert.equal(d.currentPersistence.kind, "envVar"); + assert.equal(d.value, null, "standalone helper returns null values"); + } +}); + +test("deriveNumericDescriptors_partial_fields_match_catalog_projection", () => { + // Goose: two numeric fields, no maxRounds. + const ds = deriveNumericDescriptors( + runtime("goose", { + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, + }), + ); + assert.deepEqual( + ds.map((d) => d.kind), + ["maxOutputTokens", "contextLimit"], + ); +}); + +test("deriveNumericDescriptors_matches_deriveAgentConfigFieldModel_numeric_subset", () => { + // The standalone helper must produce the same descriptor set (without values) + // that deriveAgentConfigFieldModel embeds, so surfaces that call the helper + // directly get a consistent policy with the full field model. + const runtimeEntry = runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }); + + const standalone = deriveNumericDescriptors(runtimeEntry); + const fromModel = deriveAgentConfigFieldModel({ + config, + runtime: runtimeEntry, + scope: "global", + }).fields.filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + + // Kinds and keys must match; values differ (standalone returns null, model + // reads from config). + assert.deepEqual( + standalone.map((d) => d.kind), + fromModel.map((d) => d.kind), + "descriptor kinds must match", + ); + for (let i = 0; i < standalone.length; i++) { + assert.deepEqual( + standalone[i].currentPersistence, + fromModel[i].currentPersistence, + `persistence must match for descriptor ${i}`, + ); + } +}); + +// ── NUMERIC_KIND_MIN: kind-specific input minima ────────────────────────── +// +// max output tokens and context limit must have min=1 (buzz-agent rejects 0). +// max rounds allows 0 (meaning unlimited). + +test("NUMERIC_KIND_MIN_maxOutputTokens_is_1", () => { + assert.equal(NUMERIC_KIND_MIN.maxOutputTokens, 1); +}); + +test("NUMERIC_KIND_MIN_contextLimit_is_1", () => { + assert.equal(NUMERIC_KIND_MIN.contextLimit, 1); +}); + +test("NUMERIC_KIND_MIN_maxRounds_is_0", () => { + assert.equal(NUMERIC_KIND_MIN.maxRounds, 0); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5827aedfa7..5a8b8cb1c3 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -4,6 +4,18 @@ import type { } from "@/shared/api/types"; import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; +/** + * Lifecycle status of the ACP runtime catalog query on a per-agent surface. + * + * - `loading` — query in flight; structured controls are withheld and env-var + * keys are not hidden (saved values remain visible as generic rows). + * - `ready` — query resolved; descriptors derived from `selectedRuntime`. + * - `error` — query failed; same gate as loading: no structured controls, + * keys not hidden, saved values stay visible. Distinguishable from + * "runtime not capable" (which is `ready` + no selectedRuntime). + */ +export type RuntimeCatalogStatus = "loading" | "ready" | "error"; + export type AgentConfigScope = | "onboarding" | "global" @@ -69,6 +81,13 @@ export type AgentConfigFieldDescriptor = | { kind: "acpConfigOption"; id: string; category: string }; render: "control" | "deferredUntilNativeOptionsAvailable"; value: string | null; + } + | { + kind: "maxOutputTokens" | "contextLimit" | "maxRounds"; + currentPersistence: EnvVarPersistence; + targetApplication: { kind: "envVar"; key: string }; + render: "control"; + value: string | null; }; export type AgentConfigOmission = { @@ -76,6 +95,18 @@ export type AgentConfigOmission = { reason: "ownedByModelId" | "unsupportedByHarness"; }; +/** + * A numeric tuning descriptor: one of the three env-var-backed number fields + * (max output tokens, context limit, max rounds). + * + * Defined here so both the field model derivation and the rendering surfaces + * share a single type — avoids the type being redefined in UI layers. + */ +export type NumericDescriptor = Extract< + AgentConfigFieldDescriptor, + { kind: "maxOutputTokens" | "contextLimit" | "maxRounds" } +>; + export type AgentConfigFieldModel = { fields: AgentConfigFieldDescriptor[]; omissions: AgentConfigOmission[]; @@ -86,6 +117,51 @@ function valueFromEnv(config: GlobalAgentConfig, key: string) { return config.env_vars[key]?.trim() || null; } +/** + * Derives the numeric descriptor set for a runtime from catalog fields. + * + * The returned descriptors drive `NumericTuningFields` on any surface that + * renders numeric knobs. Surfaces pass the same descriptor set to both the + * renderer and `structuredEnvKeys()` — one policy, no local rebuilding. + * + * Returns `[]` when `runtime` is undefined (catalog not yet settled, or the + * runtime has no numeric env-var fields). + */ +export function deriveNumericDescriptors( + runtime: AcpRuntimeCatalogEntry | undefined, +): NumericDescriptor[] { + if (!runtime) return []; + const ds: NumericDescriptor[] = []; + if (runtime.maxTokensEnvVar) { + ds.push({ + kind: "maxOutputTokens", + currentPersistence: { kind: "envVar", key: runtime.maxTokensEnvVar }, + targetApplication: { kind: "envVar", key: runtime.maxTokensEnvVar }, + render: "control", + value: null, + }); + } + if (runtime.contextLimitEnvVar) { + ds.push({ + kind: "contextLimit", + currentPersistence: { kind: "envVar", key: runtime.contextLimitEnvVar }, + targetApplication: { kind: "envVar", key: runtime.contextLimitEnvVar }, + render: "control", + value: null, + }); + } + if (runtime.maxRoundsEnvVar) { + ds.push({ + kind: "maxRounds", + currentPersistence: { kind: "envVar", key: runtime.maxRoundsEnvVar }, + targetApplication: { kind: "envVar", key: runtime.maxRoundsEnvVar }, + render: "control", + value: null, + }); + } + return ds; +} + /** * Derives the harness-scoped field model consumed by agent config renderers. * @@ -163,6 +239,16 @@ export function deriveAgentConfigFieldModel({ }); } + // Numeric fields — derived from the shared helper, then value-populated + // from config. Any surface needing only the descriptor structure (without + // saved values) calls deriveNumericDescriptors(runtime) directly. + for (const d of deriveNumericDescriptors(runtime)) { + fields.push({ + ...d, + value: valueFromEnv(config, d.currentPersistence.key), + }); + } + return { fields, omissions, @@ -191,3 +277,74 @@ export function getRenderableEffortField( field.kind === "effort" && field.render === "control", ); } + +/** + * Returns the env-var keys owned by the rendered descriptors on a surface. + * + * Pass only the descriptors that **actually render controls** on the surface — + * the resulting key set should be used as `EnvVarsEditor.hiddenKeys` and to + * exclude keys from baked-row generic display. + * + * Invariant: a key appears in the output only when a first-class control for + * it renders on the surface — a persisted value must never have zero editors. + * + * Per-surface consequences (assuming standard descriptor sets): + * - Global: effort key + numeric keys rendered by the descriptors + * - Per-agent buzz-agent: effort key + 3 numeric keys + * - Per-agent Goose: 2 numeric keys only — Goose effort (BUZZ_AGENT_THINKING_EFFORT) + * stays a visible generic env row because no effort control renders per-agent + * for Goose (effort migration is out of scope) + */ +export function structuredEnvKeys( + renderedDescriptors: AgentConfigFieldDescriptor[], +): string[] { + const keys: string[] = []; + for (const d of renderedDescriptors) { + if (d.render !== "control") continue; + if (d.kind === "effort" && d.currentPersistence.kind === "envVar") { + keys.push(d.currentPersistence.key); + } else if ( + d.kind === "maxOutputTokens" || + d.kind === "contextLimit" || + d.kind === "maxRounds" + ) { + keys.push(d.currentPersistence.key); + } + } + return keys; +} + +/** + * Filters a baked-env row array to exclude keys already covered by structured + * controls, preventing double-editing. The result is the set of baked rows + * that the generic env-vars editor should display. + * + * Call with the union of always-structured keys (provider/model/effort set) + * and numeric structured keys derived from `structuredEnvKeys()`. + * + * Pure — suitable for Node-layer unit tests without a component renderer. + */ +export function filterBakedGenericRows( + bakedEnv: readonly T[], + excludeKeys: ReadonlySet | readonly string[], +): T[] { + const exclude = + excludeKeys instanceof Set ? excludeKeys : new Set(excludeKeys); + return bakedEnv.filter((e) => !exclude.has(e.key)); +} + +/** + * Returns the placeholder string for a numeric tuning input. + * + * When an inherited value is present, the field shows `"Inherit ()"`. + * When absent (no global setting), the field shows `"Inherit (agent default)"`. + * + * Pure — used by NumericTuningFields and testable without a component renderer. + */ +export function numericTuningPlaceholder( + inheritedValue: string | null | undefined, +): string { + return inheritedValue + ? `Inherit (${inheritedValue})` + : "Inherit (agent default)"; +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index ce3d252203..11f68e8a56 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -24,6 +24,8 @@ import { deriveAgentConfigFieldModel, getRenderableEffortField, hasRenderableAgentConfigField, + structuredEnvKeys, + filterBakedGenericRows, } from "@/features/agents/lib/agentConfigCore"; import { getBakedProviderInheritLabel, @@ -52,7 +54,9 @@ import { } from "@/features/agents/ui/buzzAgentConfig"; import { EffortSelectField, + NumericTuningFields, useEffortAutoClear, + type NumericDescriptor, } from "@/features/agents/ui/buzzAgentModelTuningFields"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; @@ -66,7 +70,6 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { preferred_runtime: null, }; -/** Baked env keys that route to structured controls, not the generic env editor. */ const BAKED_STRUCTURED_KEYS = new Set([ "BUZZ_AGENT_PROVIDER", "BUZZ_AGENT_MODEL", @@ -103,12 +106,7 @@ export const CANONICAL_CONFIG_BEHAVIORS = { requireProviderForModelAndEffort, } as const; -/** - * Disclosure preset → the eight visibility decisions it owns. Full and - * progressive defaults expose the same controls; the progressive preset - * changes only when those controls are revealed. Exported for the contract - * test. - */ +/** Disclosure preset → the eight visibility decisions it owns. Exported for the contract test. */ export function resolveDisclosure(disclosure: AgentConfigDisclosure) { const full = disclosure !== "onboarding-essential"; return { @@ -139,14 +137,7 @@ export function shouldRevealDependentConfigFields({ ); } -/** - * Determines whether the status line beneath the Model field should render. - * - * Discovery warnings bypass the `onboarding-essential` preset so that a - * first-run failure is never silently invisible. On the happy path - * (`status === null`) the status line stays hidden in onboarding, keeping - * the page clean. - */ +/** Whether the status line under the Model field renders. Discovery warnings bypass onboarding-essential so first-run failures are never invisible. */ export function shouldShowModelStatusMessage( showDescriptions: boolean, status: { message: string; tone: string } | null, @@ -155,14 +146,8 @@ export function shouldShowModelStatusMessage( } /** - * Whether the Model control should render given discovery state. - * - * Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control - * while discovery is in flight and after a **confirmed successful empty** - * catalog (IPC resolved, no usable options) — there is nothing useful to pick. - * Discovery failures / unavailable runtimes keep the control so #2246 failure - * UI can render. Full disclosure still shows the control when Custom model is - * available. Required-model harnesses always render the control. + * Renders the Model control given discovery state. Optional-model harnesses omit it while + * discovery is loading or after confirmed successful empty; failures keep it for the #2246 UI. */ export function shouldRenderModelControl({ discoveredModelOptions, @@ -269,6 +254,19 @@ export function AgentConfigFields({ effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; + + const numericDescriptors = fieldModel.fields.filter( + (d): d is NumericDescriptor => + (d.kind === "maxOutputTokens" || + d.kind === "contextLimit" || + d.kind === "maxRounds") && + d.render === "control", + ); + const allStructuredKeys = structuredEnvKeys([ + ...(effortField ? [effortField] : []), + ...numericDescriptors, + ]); + const bakedEnvMap = Object.fromEntries(bakedEnv.map((e) => [e.key, e.value])); const bakedProvider = React.useMemo( () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, [bakedEnv], @@ -301,8 +299,12 @@ export function AgentConfigFields({ [bakedEnv], ); const bakedGenericRows = React.useMemo( - () => bakedEnv.filter((e) => !BAKED_STRUCTURED_KEYS.has(e.key)), - [bakedEnv], + () => + filterBakedGenericRows(bakedEnv, [ + ...BAKED_STRUCTURED_KEYS, + ...allStructuredKeys, + ]), + [bakedEnv, allStructuredKeys], ); const providerValue = providerFieldVisible ? (config.provider ?? "") : ""; @@ -573,16 +575,15 @@ export function AgentConfigFields({ } function handleEnvVarsChange(next: Record) { - const effort = effortPersistenceKey - ? config.env_vars[effortPersistenceKey] - : undefined; - const merged = { ...next }; - if (effortPersistenceKey && effort !== undefined) { - merged[effortPersistenceKey] = effort; - } - onConfigChange({ ...config, env_vars: merged }); + onConfigChange({ ...config, env_vars: next }); } + const handleNumericEnvVarChange = (key: string, value: string) => { + const next = { ...config.env_vars, [key]: value }; + if (value === "") delete next[key]; + onConfigChange({ ...config, env_vars: next }); + }; + // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot // migration rewrites v1→v2. Hide the legacy v1 option so it is not offered // for new selections; OSS builds show it. @@ -739,6 +740,33 @@ export function AgentConfigFields({
) : null; + const advancedEditorBlock = ( + <> + + {numericDescriptors.length > 0 ? ( + + ) : null} + + ); + const dependentContent = ( <> {providerFieldVisible && apiKeyEnvVar ? ( @@ -903,40 +931,12 @@ export function AgentConfigFields({ : PROGRESSIVE_FIELDS_TRANSITION } > - k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} - /> + {advancedEditorBlock} ) : null} ) : advancedOpen ? ( - k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} - /> + advancedEditorBlock ) : null} ) : null} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 1916b57069..12702f45ac 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -101,7 +101,7 @@ type AgentDefinitionDialogProps = { error: Error | null; isPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading?: boolean; + runtimeCatalogStatus?: "loading" | "ready" | "error"; onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, @@ -128,13 +128,14 @@ export function AgentDefinitionDialog({ error, isPending, runtimes, - runtimesLoading = false, + runtimeCatalogStatus = "ready" as const, onOpenChange, onSubmit, publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { + const runtimesLoading = runtimeCatalogStatus === "loading"; const [displayName, setDisplayName] = React.useState(""); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); @@ -394,11 +395,7 @@ export function AgentDefinitionDialog({ (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); - // Required credential env keys for this runtime + provider combination. - // Used to show required markers on the LLM provider label and amber - // locked rows in the env vars editor. - // File-layer config for the selected runtime (e.g. goose config.yaml). - // Used to silence requirements already satisfied there. + // Required credential env keys and file-layer config; silences requirements satisfied in the file layer. const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { enabled: open, }); @@ -1016,6 +1013,8 @@ export function AgentDefinitionDialog({ model={model} modelTuningRuntimeId={runtime} namePoolText={namePoolText} + catalogStatus={runtimeCatalogStatus} + selectedRuntime={selectedRuntime} onBehaviorDraftChange={(nextBehaviorDraft) => { setHasUserChanges(true); setBehaviorDraft(nextBehaviorDraft); diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index dc608da489..a875770b38 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -34,7 +34,7 @@ type AgentDialogCreateProps = { definitionError: Error | null; isDefinitionPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading: boolean; + runtimeCatalogStatus: "loading" | "ready" | "error"; onSubmitDefinition: ( input: CreatePersonaInput | UpdatePersonaInput, intent: AgentCreateIntent, @@ -68,7 +68,7 @@ type AgentDialogDefinitionEditProps = { error: Error | null; isPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading?: boolean; + runtimeCatalogStatus?: "loading" | "ready" | "error"; onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, @@ -125,7 +125,7 @@ function AgentCreateDialogRouter({ definitionError, isDefinitionPending, runtimes, - runtimesLoading, + runtimeCatalogStatus, onSubmitDefinition, }: AgentDialogCreateProps) { const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft); @@ -166,7 +166,7 @@ function AgentCreateDialogRouter({ }} open runtimes={runtimes} - runtimesLoading={runtimesLoading} + runtimeCatalogStatus={runtimeCatalogStatus} submitLabel={copy.submitLabel} title={copy.title} /> diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index d717d773e8..adfb8182a8 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -266,16 +266,10 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id that will actually be active after submit. When inheriting, - // resolve from the LINKED PERSONA's runtime — that is what will run once the - // override is cleared. Deriving from agent.agentCommand here is wrong for a - // pinned agent that just toggled "Inherit runtime from template": the override - // (e.g. a Claude pin) is still present on the record, so it would resolve to - // the old pin instead of the persona's runtime, hiding required credentials. - // Fall back to the agent.agentCommand dual-match (command path, then id) only - // when there is no linked persona or its runtime is unset. This single - // prospective id feeds BOTH the block-save gate (requiredEnvKeys) and the - // submit path so they never disagree on which runtime is being saved. + // 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. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -307,6 +301,15 @@ export function AgentInstanceEditDialog({ const llmProviderFieldVisible = runtimeSupportsLlmProviderSelection(prospectiveRuntimeId); + const prospectiveRuntime = runtimes.find( + (r) => r.id === prospectiveRuntimeId, + ); + const runtimeCatalogStatus = runtimesQuery.isLoading + ? ("loading" as const) + : runtimesQuery.isError + ? ("error" as const) + : ("ready" as const); + // One-shot focus: when the dialog opens from a card deep-link, scroll and // focus the relevant field. The effect re-runs when `llmProviderFieldVisible` // changes so a provider-field focus request fires once the field materializes. @@ -339,9 +342,8 @@ export function AgentInstanceEditDialog({ return () => cancelAnimationFrame(id); }, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]); - // Provider + env to PERSIST on submit — also fed to the credential gate so - // gate, saved record, and spawn snapshot all agree on one resolved value. - // See resolveInheritedRuntimeSubmission for the inherit/transition contract. + // Provider + env to PERSIST on submit — also fed to the credential gate so gate, saved record, + // and spawn snapshot all agree on one resolved value. See resolveInheritedRuntimeSubmission. const inheritedSubmission = React.useMemo( () => resolveInheritedRuntimeSubmission({ @@ -376,12 +378,8 @@ export function AgentInstanceEditDialog({ inheritedEnvVars: inheritedEnvVarsForAdvanced, } = useAgentDialogDefaults({ inheritedEnvVars, open }); - // Runtime/provider-required credential state, derived from the PROSPECTIVE - // post-submit runtime — see the hook for the inherit-transition rationale. - // Pass globalProvider so the hook uses it as a fallback when the per-agent - // provider is empty (global-provider-only configs must surface required keys). - // Pass globalEnvVars so keys satisfied by global config are excluded from - // requiredEnvKeys and do not block Save (display and gate agree). + // Runtime/provider-required credential state for the PROSPECTIVE post-submit runtime. + // globalProvider/globalEnvVars: fallback for empty per-agent provider; keys satisfied globally don't block Save. const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } = useRequiredCredentialState({ open, @@ -1199,6 +1197,8 @@ export function AgentInstanceEditDialog({ parallelism={parallelism} provider={effectiveProvider} requiredEnvKeys={advancedRequiredEnvKeys} + catalogStatus={runtimeCatalogStatus} + selectedRuntime={prospectiveRuntime} systemPrompt={systemPrompt} onAcpCommandChange={setAcpCommand} onAgentArgsChange={setAgentArgs} diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index 0d01cbbcd1..b72669e5f6 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -22,7 +22,7 @@ export function AgentManagementDialogs() { }} onSubmitDefinition={management.submitCreate} runtimes={management.runtimes} - runtimesLoading={management.runtimesLoading} + runtimeCatalogStatus={management.runtimeCatalogStatus} /> ) : null} {management.createdAgent ? ( @@ -51,7 +51,7 @@ export function AgentManagementDialogs() { onSubmit={management.submitUpdate} open runtimes={management.runtimes} - runtimesLoading={management.runtimesLoading} + runtimeCatalogStatus={management.runtimeCatalogStatus} submitLabel="Save changes" title="Edit agent" /> diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 3d1673c365..720d6e62ad 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -319,7 +319,13 @@ export function AgentsView() { }} onSubmitDefinition={personas.handleSubmit} runtimes={personas.acpRuntimesQuery.data ?? []} - runtimesLoading={personas.acpRuntimesQuery.isLoading} + runtimeCatalogStatus={ + personas.acpRuntimesQuery.isLoading + ? "loading" + : personas.acpRuntimesQuery.isError + ? "error" + : "ready" + } /> ) : null} {agents.agentToAddToChannel ? ( @@ -368,7 +374,13 @@ export function AgentsView() { isPending={personas.isPending} mode="definition-edit" runtimes={personas.acpRuntimesQuery.data ?? []} - runtimesLoading={personas.acpRuntimesQuery.isLoading} + runtimeCatalogStatus={ + personas.acpRuntimesQuery.isLoading + ? "loading" + : personas.acpRuntimesQuery.isError + ? "error" + : "ready" + } onOpenChange={(open) => { if (!open) { personas.setPersonaDialogState(null); diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 972c4e287e..56685fd6c4 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; @@ -9,9 +10,21 @@ import { PERSONA_LABEL_OPTIONAL_CLASS, } from "./agentConfigOptions"; import type { AgentPersona } from "@/shared/api/types"; -import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; -import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + BuzzAgentModelTuningFields, + NumericTuningFields, +} from "./buzzAgentModelTuningFields"; +import { + isBuzzAgentRuntime, + BUZZ_AGENT_THINKING_EFFORT, +} from "./buzzAgentConfig"; import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism"; +import { + deriveNumericDescriptors, + structuredEnvKeys, + type RuntimeCatalogStatus, +} from "../lib/agentConfigCore"; export function EditAgentAdvancedFields({ acpCommand, @@ -30,6 +43,8 @@ export function EditAgentAdvancedFields({ parallelism, provider, requiredEnvKeys, + catalogStatus = "ready", + selectedRuntime, systemPrompt, onAcpCommandChange, onAgentArgsChange, @@ -55,7 +70,7 @@ export function EditAgentAdvancedFields({ model?: string; /** * The actual/prospective runtime id used to decide whether to show the - * buzz-agent model-tuning fields. Uses `prospectiveRuntimeId` from + * buzz-agent effort-tuning field. Uses `prospectiveRuntimeId` from * EditAgentDialog — the resolved runtime, not the "inherit"/"custom" sentinel. */ modelTuningRuntimeId: string; @@ -63,6 +78,24 @@ export function EditAgentAdvancedFields({ /** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */ provider?: string; requiredEnvKeys: readonly string[]; + /** + * Lifecycle status of the runtime catalog query. Controls the numeric-tuning + * gate and hidden-key behaviour: + * - `loading` or `error`: no structured controls; keys not hidden — saved + * values stay visible as generic rows. + * - `ready`: descriptors derived from `selectedRuntime` (empty when the + * runtime has no numeric env-var fields). + * + * Defaults to `"ready"` so existing callers without the catalog query do not + * need to change. + */ + catalogStatus?: RuntimeCatalogStatus; + /** + * The catalog entry for the prospective runtime. Drives descriptor-based + * numeric tuning fields (max output tokens / context limit / max rounds). + * When undefined after the catalog has settled, no numeric controls render. + */ + selectedRuntime?: AcpRuntimeCatalogEntry; systemPrompt: string; onAcpCommandChange: (value: string) => void; onAgentArgsChange: (value: string) => void; @@ -72,6 +105,29 @@ export function EditAgentAdvancedFields({ onAutoRestartChange: (value: boolean) => void; onSystemPromptChange: (value: string) => void; }) { + // Numeric tuning descriptors — gate on catalog status so that loading/error + // never collapses to "no controls": keys stay visible as generic rows. + const numericDescriptors = React.useMemo( + () => + catalogStatus === "ready" + ? deriveNumericDescriptors(selectedRuntime) + : [], + [catalogStatus, selectedRuntime], + ); + + // Build the effective hidden-key list: caller's secrets + effort key (when + // rendered by BuzzAgentModelTuningFields) + numeric keys via structuredEnvKeys. + const effectiveHiddenKeys = React.useMemo( + () => [ + ...hiddenEnvKeys, + ...(isBuzzAgentRuntime(modelTuningRuntimeId) + ? [BUZZ_AGENT_THINKING_EFFORT] + : []), + ...structuredEnvKeys(numericDescriptors), + ], + [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], + ); + return (
{/* Inherit runtime from template */} @@ -248,7 +304,7 @@ export function EditAgentAdvancedFields({ - {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */} + {/* Descriptor-driven numeric tuning knobs — shown when the catalog has settled + and the runtime exposes numeric env-var fields. */} + {numericDescriptors.length > 0 ? ( + { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + onEnvVarsChange(next); + }} + /> + ) : null} + + {/* Effort-tuning knob — only shown for buzz-agent. */} {isBuzzAgentRuntime(modelTuningRuntimeId) ? ( { "annotation must not appear when its key is not in the env map", ); }); + +// ── buildRecord with hiddenKeys: structured-field preservation ──────────── +// +// These tests exercise the exported buildRecord(nextRows, value, requiredKeys, +// hiddenKeys) using the real implementation. hiddenKeys are structured-field +// env vars (e.g. BUZZ_AGENT_MAX_ROUNDS) that are owned by first-class controls +// outside the editor — they must survive onChange cycles even though they +// never appear as generic rows. +// +// Four scenarios from rev 4: +// 1. Edit an unrelated generic row → hidden (tuning) key is unchanged. +// 2. Runtime switch then generic edit: after switching to a new runtime, +// the new runtime's hidden keys survive; old runtime keys appear as +// generic rows and survive via toRecord, not hidden-key preservation. +// 3. Baked numeric key excluded via real descriptor/helper path: uses the +// production deriveAgentConfigFieldModel + structuredEnvKeys helpers to +// derive the hidden set, then verifies toRows excludes the numeric key. +// 4. Clearing a structured override → placeholder returns: after the user +// clears a structured field (key absent from value), buildRecord must +// not reintroduce it, leaving the structured field free to show the +// Inherit placeholder. + +test("buildRecord_hidden_tuning_key_unchanged_when_generic_row_edited", () => { + // Structured field set BUZZ_AGENT_MAX_ROUNDS to "50"; it lives in value + // as a hiddenKey. User then edits a generic env var via the row editor. + // The tuning key must survive the buildRecord emit cycle unchanged. + const value = { BUZZ_AGENT_MAX_ROUNDS: "50", MY_VAR: "old" }; + const nextRows = [{ id: "r1", key: "MY_VAR", value: "new" }]; + const record = buildRecordUtil( + nextRows, + value, + [], + ["BUZZ_AGENT_MAX_ROUNDS"], + ); + + assert.equal( + record.BUZZ_AGENT_MAX_ROUNDS, + "50", + "hidden tuning key must survive when an unrelated generic row is edited", + ); + assert.equal(record.MY_VAR, "new", "generic row edit applied"); +}); + +test("buildRecord_runtime_switch_new_hiddenKeys_then_generic_edit", () => { + // Scenario 2: runtime switch then generic edit. + // + // Before switch: agent is buzz-agent with BUZZ_AGENT_MAX_ROUNDS = "50" stored + // in value (set via the numeric tuning control). After switching to Goose, + // the buzz-agent key is no longer hidden — it becomes a visible generic row. + // The test verifies: + // (a) After the switch, the old buzz-agent key appears as a generic row + // (toRows with the new Goose hidden set projects it). + // (b) After a generic-row edit, buildRecord preserves BOTH the old-runtime + // key (now a generic row) and the new-runtime hidden key. + // (c) An unset new-runtime hidden key is not introduced. + + // Derive both descriptor sets from real runtime objects. + const buzzAgentRuntime = { + id: "buzz-agent", + label: "Buzz Agent", + avatarUrl: "", + availability: "available", + command: "buzz-agent", + binaryPath: "buzz-agent", + defaultArgs: [], + mcpCommand: null, + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_CONTEXT_LIMIT", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + const gooseRuntime = { + id: "goose", + label: "Goose", + avatarUrl: "", + availability: "available", + command: "goose", + binaryPath: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: true, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + + const buzzDescriptors = deriveNumericDescriptors(buzzAgentRuntime); + const gooseDescriptors = deriveNumericDescriptors(gooseRuntime); + const buzzHiddenKeys = structuredEnvKeys(buzzDescriptors); + const gooseHiddenKeys = structuredEnvKeys(gooseDescriptors); + + // Sanity-check that BUZZ_AGENT_MAX_ROUNDS is hidden under buzz-agent but not + // under Goose — that contrast is what makes it become a generic row. + assert.ok( + buzzHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"), + "BUZZ_AGENT_MAX_ROUNDS must be hidden under buzz-agent descriptors", + ); + assert.equal( + gooseHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"), + false, + "BUZZ_AGENT_MAX_ROUNDS must not be hidden under Goose descriptors", + ); + + // Pre-switch value: buzz-agent max-rounds was set, GOOSE_MAX_TOKENS was + // already set (e.g. user configured it before switching back), plus a + // generic user var. GOOSE_MAX_TOKENS is a hidden key under the Goose + // descriptor set, so it must survive buildRecord() via hiddenKeys. + const valueBeforeSwitch = { + BUZZ_AGENT_MAX_ROUNDS: "50", + GOOSE_MAX_TOKENS: "16384", + USER_VAR: "original", + }; + + // After the switch to Goose, toRows is reproj with the new (Goose) hidden + // set. BUZZ_AGENT_MAX_ROUNDS is no longer hidden → appears as a generic row. + // GOOSE_MAX_TOKENS IS hidden under Goose → must not appear in generic rows. + const rowsAfterSwitch = toRows(valueBeforeSwitch, new Set(gooseHiddenKeys)); + assert.ok( + rowsAfterSwitch.some((r) => r.key === "BUZZ_AGENT_MAX_ROUNDS"), + "old-runtime key must become a generic row after the switch", + ); + assert.equal( + rowsAfterSwitch.some((r) => r.key === "GOOSE_MAX_TOKENS"), + false, + "new-runtime hidden key must not appear as a generic row after the switch", + ); + + // User edits the generic USER_VAR row. + const editedRows = rowsAfterSwitch.map((r) => + r.key === "USER_VAR" ? { ...r, value: "updated" } : r, + ); + + // buildRecord: old-runtime key survives via toRecord (it's now a generic + // row); new-runtime Goose hidden key survives via hiddenKeys (carried + // through from value). An unset Goose key must not be introduced. + const record = buildRecordUtil( + editedRows, + valueBeforeSwitch, + [], + gooseHiddenKeys, + ); + + assert.equal( + record.BUZZ_AGENT_MAX_ROUNDS, + "50", + "old-runtime key must survive as a generic row value after switch", + ); + assert.equal( + record.GOOSE_MAX_TOKENS, + "16384", + "new-runtime hidden key must survive buildRecord via hiddenKeys", + ); + assert.equal(record.USER_VAR, "updated", "generic row edit applied"); + assert.equal( + "GOOSE_CONTEXT_LIMIT" in record, + false, + "unset new-runtime hidden key must not be introduced", + ); +}); + +test("filterBakedGenericRows_numeric_baked_key_excluded_and_placeholder_shown", () => { + // Scenario 3: baked numeric key excluded via the real production helper. + // + // The global baked env contains BUZZ_AGENT_MAX_OUTPUT_TOKENS = "4096" + // (the baked value shipped with the agent). The production + // filterBakedGenericRows path must exclude this key from the generic + // baked-row display so it isn't editable twice, while the structured + // numeric input shows the inherited placeholder via numericTuningPlaceholder. + const buzzAgentRuntime = { + id: "buzz-agent", + label: "Buzz Agent", + avatarUrl: "", + availability: "available", + command: "buzz-agent", + binaryPath: "buzz-agent", + defaultArgs: [], + mcpCommand: null, + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: null, + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + + const numericDescriptors = deriveNumericDescriptors(buzzAgentRuntime); + const numericStructuredKeys = structuredEnvKeys(numericDescriptors); + + assert.ok( + numericStructuredKeys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + "numeric key must appear in structured keys via production helpers", + ); + + // Simulate the baked env: BUZZ_AGENT_MAX_OUTPUT_TOKENS is baked, plus a + // non-structured baked var. + const bakedEnv = [ + { key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", value: "4096" }, + { key: "SOME_OTHER_BAKED_VAR", value: "hello" }, + ]; + + // filterBakedGenericRows must exclude the numeric key. + const genericRows = filterBakedGenericRows(bakedEnv, numericStructuredKeys); + + assert.equal( + genericRows.some((r) => r.key === "BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + false, + "baked numeric key must be excluded from generic baked rows", + ); + assert.ok( + genericRows.some((r) => r.key === "SOME_OTHER_BAKED_VAR"), + "non-structured baked var must remain in generic rows", + ); + + // The structured numeric input shows the inherited placeholder for the + // baked value via numericTuningPlaceholder. + const bakedValue = "4096"; + assert.equal( + numericTuningPlaceholder(bakedValue), + "Inherit (4096)", + "structured placeholder must reflect the baked value", + ); + assert.equal( + numericTuningPlaceholder(undefined), + "Inherit (agent default)", + "structured placeholder without baked value shows agent-default text", + ); +}); + +test("buildRecord_clearing_structured_field_allows_placeholder_to_return", () => { + // Scenario 4: clearing a structured override → placeholder returns. + // + // Step 1: value has BUZZ_AGENT_MAX_ROUNDS = "50" (user set it via the + // structured field). BUZZ_AGENT_MAX_ROUNDS is in hiddenKeys. + // Step 2: user clears the structured field → onEnvVarChange(key, "") + // removes the key from value (value no longer contains it). + // Step 3: after the clear, buildRecord must not reintroduce the key. + // Step 4: with the key absent from value, numericTuningPlaceholder over + // the (now-empty) inheritedEnvVars shows "Inherit (agent default)" + // — the numeric field's empty-state placeholder. + + // After the clear, value no longer contains BUZZ_AGENT_MAX_ROUNDS. + const valueAfterClear = { MY_VAR: "foo" }; + const nextRows = [{ id: "r1", key: "MY_VAR", value: "updated" }]; + + const record = buildRecordUtil( + nextRows, + valueAfterClear, + [], + ["BUZZ_AGENT_MAX_ROUNDS"], + ); + + assert.equal( + "BUZZ_AGENT_MAX_ROUNDS" in record, + false, + "cleared structured key must not be reintroduced by buildRecord", + ); + assert.equal(record.MY_VAR, "updated"); + + // With the key cleared, the inherited value is also absent (not set + // globally). numericTuningPlaceholder returns the agent-default text — + // the placeholder that renders in the structured input. + const inheritedAfterClear = undefined; + assert.equal( + numericTuningPlaceholder(inheritedAfterClear), + "Inherit (agent default)", + "numeric input must show Inherit (agent default) after clear when no global override", + ); + + // If a global override IS set, the placeholder shows that value instead. + const inheritedGlobal = "25"; + assert.equal( + numericTuningPlaceholder(inheritedGlobal), + "Inherit (25)", + "numeric input must show Inherit () when a global override exists", + ); +}); diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx index 91e5b07622..08496eece5 100644 --- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx +++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx @@ -44,7 +44,9 @@ export function toRows( * Collapse an ordered row list back to a record, skipping rows with empty * keys. Exported for unit tests. */ -export function toRecord(rows: Row[]): EnvVarsValue { +export function toRecord( + rows: readonly { key: string; value: string }[], +): EnvVarsValue { const out: EnvVarsValue = {}; for (const row of rows) { // Empty key = user is mid-edit; skip it so we don't poison the record. @@ -177,6 +179,27 @@ type EnvVarsEditorProps = { type Row = { id: string; key: string; value: string }; +/** + * Pure record builder: merges `toRecord(nextRows)` with the current values of + * `requiredKeys` and `hiddenKeys` from `value`. Required and hidden keys are + * excluded from the row state (`skipKeys`), so this merge is the only place + * their current values survive an `onChange` emit cycle. + * + * Exported for unit testing. `EnvVarsEditor` calls this internally. + */ +export function buildRecord( + nextRows: readonly { key: string; value: string }[], + value: EnvVarsValue, + requiredKeys: readonly string[], + hiddenKeys: readonly string[], +): EnvVarsValue { + const base: EnvVarsValue = {}; + for (const key of [...requiredKeys, ...hiddenKeys]) { + if (key in value) base[key] = value[key]; + } + return { ...base, ...toRecord(nextRows) }; +} + /** * A flat key/value editor for environment variables. * @@ -241,18 +264,6 @@ export function EnvVarsEditor({ } }, [value, skipKeys]); - // Build the emitted record: normal rows + required-key values preserved - // from `value`. Required keys are never in `rows`, so `toRecord(rows)` - // would silently drop any required secret the user just typed unless we - // merge them back explicitly. - function buildRecord(nextRows: Row[]): EnvVarsValue { - const base: EnvVarsValue = {}; - for (const key of [...requiredKeys, ...hiddenKeys]) { - if (key in value) base[key] = value[key]; - } - return { ...base, ...toRecord(nextRows) }; - } - // Ref map: key → required-value Input element. Populated via callback refs // on each required-key row's value Input so focus can be dispatched directly // without any DOM walking through presentation classes. @@ -294,7 +305,7 @@ export function EnvVarsEditor({ function emit(next: Row[]) { setRows(next); - const record = buildRecord(next); + const record = buildRecord(next, value, requiredKeys, hiddenKeys); lastEmitted.current = record; onChange(record); } diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index b7d1903784..1ecd98b83f 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -1,20 +1,33 @@ +import * as React from "react"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; -import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { + isBuzzAgentRuntime, + BUZZ_AGENT_THINKING_EFFORT, +} from "./buzzAgentConfig"; import { AGENT_PARALLELISM_HELP, AGENT_PARALLELISM_PLACEHOLDER, } from "../lib/agentParallelism"; -import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; +import { + BuzzAgentModelTuningFields, + NumericTuningFields, +} from "./buzzAgentModelTuningFields"; import { CARD_MINT_KEY_ANNOTATIONS, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, } from "./agentConfigOptions"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + deriveNumericDescriptors, + structuredEnvKeys, + type RuntimeCatalogStatus, +} from "../lib/agentConfigCore"; export function PersonaAdvancedFields({ behaviorDraft, @@ -31,6 +44,8 @@ export function PersonaAdvancedFields({ requiredEnvKeys = [], fileSatisfiedEnvKeys = [], hiddenEnvKeys = [], + catalogStatus = "ready" as RuntimeCatalogStatus, + selectedRuntime, }: { behaviorDraft: PersonaBehaviorDraft; disabled: boolean; @@ -40,7 +55,7 @@ export function PersonaAdvancedFields({ inheritedEnvVars?: EnvVarsValue; /** Active LLM model — forwarded to BuzzAgentModelTuningFields for effort filtering. */ model?: string; - /** Runtime id for the buzz-agent tuning knobs visibility gate. */ + /** Runtime id for the buzz-agent effort-tuning knob visibility gate. */ modelTuningRuntimeId?: string; namePoolText: string; onBehaviorDraftChange: (value: PersonaBehaviorDraft) => void; @@ -51,7 +66,42 @@ export function PersonaAdvancedFields({ requiredEnvKeys?: readonly string[]; fileSatisfiedEnvKeys?: readonly string[]; hiddenEnvKeys?: readonly string[]; + /** + * Lifecycle status of the runtime catalog query. Controls the numeric-tuning + * gate and hidden-key behaviour: + * - `loading` or `error`: no structured controls; keys not hidden — saved + * values stay visible as generic rows. + * - `ready`: descriptors derived from `selectedRuntime` (empty when the + * runtime has no numeric env-var fields). + */ + catalogStatus?: RuntimeCatalogStatus; + /** + * The catalog entry for the selected runtime. Drives descriptor-based + * numeric tuning fields. When undefined after the catalog has settled, + * no numeric controls render. + */ + selectedRuntime?: AcpRuntimeCatalogEntry; }) { + // Numeric tuning descriptors — gate on catalog status so that loading/error + // never collapses to "no controls": keys stay visible as generic rows. + const numericDescriptors = React.useMemo( + () => + catalogStatus === "ready" + ? deriveNumericDescriptors(selectedRuntime) + : [], + [catalogStatus, selectedRuntime], + ); + + const effectiveHiddenKeys = React.useMemo( + () => [ + ...hiddenEnvKeys, + ...(isBuzzAgentRuntime(modelTuningRuntimeId) + ? [BUZZ_AGENT_THINKING_EFFORT] + : []), + ...structuredEnvKeys(numericDescriptors), + ], + [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], + ); return (
- {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */} + {/* Descriptor-driven numeric tuning knobs — shown when catalog has settled + and the runtime exposes numeric env-var fields. */} + {numericDescriptors.length > 0 ? ( + { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + onEnvVarsChange(next); + }} + /> + ) : null} + + {/* Effort-tuning knob — only shown for buzz-agent. */} {isBuzzAgentRuntime(modelTuningRuntimeId) ? ( ) : null} {personas.createdAgent ? ( diff --git a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx index 938d5edf5b..7fa87c4e6b 100644 --- a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx +++ b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx @@ -9,14 +9,13 @@ import * as React from "react"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import type { EnvVarsValue } from "./EnvVarsEditor"; +import type { NumericDescriptor } from "../lib/agentConfigCore"; +import { numericTuningPlaceholder } from "../lib/agentConfigCore"; import { AgentDropdownSelect, type AgentDropdownOption, } from "./agentConfigControls"; import { - BUZZ_AGENT_MAX_CONTEXT_TOKENS, - BUZZ_AGENT_MAX_OUTPUT_TOKENS, - BUZZ_AGENT_MAX_ROUNDS, BUZZ_AGENT_THINKING_EFFORT, BUZZ_AGENT_THINKING_EFFORT_VALUES, getProviderEffortConfig, @@ -201,6 +200,98 @@ export function useEffortAutoClear({ }, [effortValid, currentEffort]); } +export type { NumericDescriptor }; + +const NUMERIC_KIND_LABELS: Record = { + maxOutputTokens: "Max output tokens", + contextLimit: "Context limit", + maxRounds: "Max rounds", +}; + +const NUMERIC_KIND_DESCRIPTIONS: Record = { + maxOutputTokens: + "Maximum tokens the LLM may generate per response. Leave blank to inherit.", + contextLimit: + "Maximum context window tokens tracked before a handoff. Leave blank to inherit.", + maxRounds: + "Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank to inherit.", +}; + +const NUMERIC_KIND_TEST_IDS: Record = { + maxOutputTokens: "numeric-max-output-tokens-input", + contextLimit: "numeric-context-limit-input", + maxRounds: "numeric-max-rounds-input", +}; + +/** + * Input `min` attribute per numeric kind. + * + * - `maxOutputTokens` / `contextLimit`: minimum 1 — the buzz-agent runtime + * rejects 0 for these fields (crates/buzz-agent/src/config.rs:921-928). + * - `maxRounds`: 0 is valid (means unlimited). + */ +export const NUMERIC_KIND_MIN: Record = { + maxOutputTokens: 1, + contextLimit: 1, + maxRounds: 0, +}; + +/** + * Descriptor-driven numeric tuning inputs. + * + * Renders a grid of number inputs for every numeric descriptor in `descriptors`. + * Label and help text are keyed by descriptor kind — the same copy renders on + * both the global defaults surface and per-agent dialogs. + */ +export function NumericTuningFields({ + descriptors, + envVars, + inheritedEnvVars, + onEnvVarChange, +}: { + /** Numeric descriptors to render. Empty array → renders nothing. */ + descriptors: NumericDescriptor[]; + envVars: EnvVarsValue; + inheritedEnvVars: EnvVarsValue; + onEnvVarChange: (key: string, value: string) => void; +}) { + if (descriptors.length === 0) return null; + return ( +
+ {descriptors.map((d) => { + const key = d.currentPersistence.key; + const label = NUMERIC_KIND_LABELS[d.kind]; + const description = NUMERIC_KIND_DESCRIPTIONS[d.kind]; + const testId = NUMERIC_KIND_TEST_IDS[d.kind]; + const inheritedVal = inheritedEnvVars[key]; + return ( +
+ + onEnvVarChange(key, event.target.value)} + placeholder={numericTuningPlaceholder(inheritedVal)} + step="1" + type="number" + value={envVars[key] ?? ""} + /> +

+ {description} +

+
+ ); + })} +
+ ); +} + export function BuzzAgentModelTuningFields({ envVars, inheritedEnvVars, @@ -257,105 +348,6 @@ export function BuzzAgentModelTuningFields({ blank to inherit from the global or persona default.

- - {/* Max Rounds */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_ROUNDS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_ROUNDS] ?? ""} - /> -

- Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank - to inherit. -

-
- - {/* Max Output Tokens */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_OUTPUT_TOKENS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS] ?? ""} - /> -

- Maximum tokens the LLM may generate per response. Leave blank to - inherit. -

-
- - {/* Context Limit */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_CONTEXT_TOKENS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS] ?? ""} - /> -

- Maximum context window tokens buzz-agent tracks before a handoff. - Leave blank to inherit. -

-
); diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index f4cdb895ef..066f7949a9 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -301,7 +301,11 @@ export function useAgentManagement() { ...createdAgentAttachment, isPending, runtimes: runtimesQuery.data ?? [], - runtimesLoading: runtimesQuery.isLoading, + runtimeCatalogStatus: runtimesQuery.isLoading + ? ("loading" as const) + : runtimesQuery.isError + ? ("error" as const) + : ("ready" as const), submitCreate, submitUpdate, dismiss, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index af30728d6a..cb188dd008 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -957,6 +957,7 @@ export function UserProfilePanel({ personaToExportSnapshot={personaToExportSnapshot} resolvedPersona={resolvedPersona} runtimes={acpRuntimesQuery.data ?? []} + runtimesError={acpRuntimesQuery.isError} runtimesLoading={acpRuntimesQuery.isLoading} updateError={ updatePersonaMutation.error instanceof Error diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx index 2de2e2f716..9fae1bd889 100644 --- a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -29,6 +29,7 @@ export function UserProfilePersonaDialogs({ resolvedPersona, runtimes, runtimesLoading, + runtimesError = false, updateError, onCloseCardMint, onCloseDelete, @@ -50,6 +51,7 @@ export function UserProfilePersonaDialogs({ resolvedPersona: AgentPersona | undefined; runtimes: AcpRuntimeCatalogEntry[]; runtimesLoading: boolean; + runtimesError?: boolean; updateError: Error | null; onCloseCardMint: () => void; onCloseDelete: () => void; @@ -59,6 +61,11 @@ export function UserProfilePersonaDialogs({ onExportSnapshot: (persona: AgentPersona) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; }) { + const runtimeCatalogStatus = runtimesLoading + ? "loading" + : runtimesError + ? "error" + : ("ready" as const); return ( <> { if (!open) { onCloseDialog(); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..bb56bc18e5 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -188,6 +188,9 @@ export type RawAcpRuntimeCatalogEntry = { model_env_var?: string | null; provider_env_var?: string | null; thinking_env_var?: string | null; + max_tokens_env_var?: string | null; + context_limit_env_var?: string | null; + max_rounds_env_var?: string | null; install_hint: string; install_instructions_url: string; can_auto_install: boolean; @@ -199,10 +202,7 @@ export type RawAcpRuntimeCatalogEntry = { auth_status: AuthStatus; login_hint?: string; source: "builtin" | "preset" | "custom"; - /** - * Definition-level env vars for `source: custom` entries. - * Omitted/absent for builtin and preset — skipped in Rust serialization when empty. - */ + /** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */ definition_env?: Record; }; @@ -749,6 +749,9 @@ export function fromRawAcpRuntimeCatalogEntry( modelEnvVar: entry.model_env_var ?? null, providerEnvVar: entry.provider_env_var ?? null, thinkingEnvVar: entry.thinking_env_var ?? null, + maxTokensEnvVar: entry.max_tokens_env_var ?? null, + contextLimitEnvVar: entry.context_limit_env_var ?? null, + maxRoundsEnvVar: entry.max_rounds_env_var ?? null, installHint: entry.install_hint, installInstructionsUrl: entry.install_instructions_url, canAutoInstall: entry.can_auto_install, @@ -1024,9 +1027,8 @@ export type RuntimeFileConfigSubset = { }; /** - * Get the file-layer config for a runtime so dialogs can show - * "Set in goose config" instead of surfacing a false required-field marker. - * Returns `null` when the runtime has no config file or it cannot be parsed. + * Get the file-layer config for a runtime so dialogs can show "Set in goose config" instead of + * surfacing a false required-field marker. Returns `null` when unavailable or unparseable. */ export async function getRuntimeFileConfig( runtimeId: string, @@ -1040,13 +1042,9 @@ export async function getRuntimeFileConfig( } /** - * Return the key names of all non-empty baked build env vars. - * - * Internal (Block) builds bake provider credentials into the binary at compile - * time. This returns the *key names only* — never the values — so dialogs can - * treat them as satisfied without exposing secrets to the frontend. - * - * OSS builds return an empty array (no baked env). + * Return the key names of all non-empty baked build env vars. Internal (Block) builds bake + * provider credentials into the binary at compile time; this returns *key names only* (never + * values) so dialogs treat them as satisfied without exposing secrets. OSS builds return []. */ export async function getBakedBuildEnvKeys(): Promise { return invokeTauri("get_baked_build_env_keys"); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d0e8ee0047..fd2c71bced 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -517,6 +517,9 @@ export type AcpRuntimeCatalogEntry = { providerEnvVar: string | null; /** Environment variable used to apply thinking effort, when supported. */ thinkingEnvVar: string | null; + maxTokensEnvVar: string | null; + contextLimitEnvVar: string | null; + maxRoundsEnvVar: string | null; installHint: string; installInstructionsUrl: string; canAutoInstall: boolean; @@ -529,12 +532,7 @@ export type AcpRuntimeCatalogEntry = { authStatus: AuthStatus; /** Hint for completing authentication; null when not applicable or already logged in. */ loginHint: string | null; - /** - * Whether this entry is compiled into the app ("builtin"), a bundled preset - * ("preset" — PATH-probed, not editable/deletable), or loaded from a user - * JSON file in `custom_harnesses/` ("custom"). Controls editability in the - * UI — only "custom" entries can be edited or deleted. - */ + /** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */ source: "builtin" | "preset" | "custom"; /** * Definition-level environment variables for `source: custom` entries. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6520dd760d..9c6618c83a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -74,7 +74,7 @@ type MockCommandAvailability = { resolvedPath?: string | null; }; -type MockManagedAgentSeed = { +export type MockManagedAgentSeed = { pubkey: string; name: string; avatarUrl?: string | null; @@ -91,6 +91,8 @@ type MockManagedAgentSeed = { autoRestartOnConfigChange?: boolean; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; + /** Per-agent env vars seeded into the mock store. */ + envVars?: Record; }; type MockManagedAgentRuntimeSeed = { @@ -214,6 +216,8 @@ type E2eConfig = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][]; acpRuntimesDelayMs?: number; + /** When true, the catalog discovery call throws — simulates a failed query. */ + acpRuntimesError?: boolean; acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; @@ -2086,6 +2090,24 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { const now = new Date().toISOString(); const status = seed.status ?? "stopped"; + // Resolve agent_command and agent_args from the well-known default catalog + // so the fixture mirrors real wire shape. Hardcoding ["acp"] for all runtimes + // is incorrect: buzz-agent ships with no default args. + const DEFAULT_RUNTIME_COMMAND: Record< + string, + { command: string; args: string[] } + > = { + goose: { command: "goose", args: ["acp"] }, + "buzz-agent": { command: "buzz-agent", args: [] }, + claude: { command: "claude", args: [] }, + codex: { command: "codex", args: [] }, + }; + const catalogEntry = seed.runtime + ? DEFAULT_RUNTIME_COMMAND[seed.runtime] + : undefined; + const agentCommand = catalogEntry?.command ?? seed.runtime ?? "goose"; + const agentArgs = catalogEntry?.args ?? ["acp"]; + return { pubkey: seed.pubkey, name: seed.name, @@ -2095,8 +2117,8 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { runtime: seed.runtime ?? null, relay_url: DEFAULT_RELAY_WS_URL, acp_command: "buzz-acp", - agent_command: "goose", - agent_args: ["acp"], + agent_command: agentCommand, + agent_args: agentArgs, mcp_command: "", turn_timeout_seconds: 320, idle_timeout_seconds: null, @@ -2105,7 +2127,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { system_prompt: null, avatar_url: seed.avatarUrl ?? null, model: null, - env_vars: {}, + env_vars: { ...(seed.envVars ?? {}) }, status, pid: status === "running" ? 42000 + mockManagedAgents.length : null, created_at: now, @@ -7165,6 +7187,28 @@ function withMockRuntimeConfigMetadata( : runtime.id === "goose" ? "GOOSE_THINKING_EFFORT" : null, + max_tokens_env_var: + "max_tokens_env_var" in runtime + ? runtime.max_tokens_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_OUTPUT_TOKENS" + : runtime.id === "goose" + ? "GOOSE_MAX_TOKENS" + : null, + context_limit_env_var: + "context_limit_env_var" in runtime + ? runtime.context_limit_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_CONTEXT_TOKENS" + : runtime.id === "goose" + ? "GOOSE_CONTEXT_LIMIT" + : null, + max_rounds_env_var: + "max_rounds_env_var" in runtime + ? runtime.max_rounds_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_ROUNDS" + : null, }; } @@ -7182,6 +7226,10 @@ async function handleDiscoverAcpRuntimes( }); } + if (config?.mock?.acpRuntimesError) { + throw new Error("Mocked catalog discovery failure"); + } + const afterInstallSequence = config?.mock?.acpRuntimesCatalogAfterInstallSequence; if (mockInstallCompleted && afterInstallSequence?.length) { diff --git a/desktop/tests/e2e/agent-numeric-tuning.spec.ts b/desktop/tests/e2e/agent-numeric-tuning.spec.ts new file mode 100644 index 0000000000..dc70c8b12e --- /dev/null +++ b/desktop/tests/e2e/agent-numeric-tuning.spec.ts @@ -0,0 +1,372 @@ +/** + * Playwright regression tests for the numeric tuning fields (max output tokens, + * context limit, max rounds) on both the global Agent Defaults surface and the + * per-agent Advanced section. + * + * Covers: + * 1. Global defaults Advanced shows numeric inputs for buzz-agent. + * 2. Global defaults Advanced hides numeric inputs for non-capable runtimes. + * 3. Per-agent Goose: saving a max-tokens value globally surfaces as + * Inherit () placeholder in the per-agent edit dialog. + * 4. Delayed catalog: while loading, saved tuning env vars stay visible + * as generic rows (not silently dropped); structured controls appear + * once the catalog settles. + * 5. Failed catalog: when discovery errors, saved tuning env vars remain + * visible as generic rows (never the "unsupported" empty state). + */ + +import { expect, test } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +async function openAiDefaultsSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(".animate-spin").first()).not.toBeVisible({ + timeout: 5_000, + }); +} + +async function openEditAgentDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + const agentButton = page.getByRole("button", { + name: `${agentName} agent profile`, + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +test("global_advanced_buzz_agent_shows_all_numeric_controls", async ({ + page, +}) => { + // The mock bridge's withMockRuntimeConfigMetadata injects the numeric env var + // fields for buzz-agent. When buzz-agent is selected and Advanced is opened, + // all three numeric inputs must be visible. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: null, + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + auth_status: { status: "not_applicable" }, + }, + ], + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + }); + + await openAiDefaultsSettings(page); + + // Open the Advanced section. The settings card uses disclosure="full" (no + // animation wrapper), so we click the toggle and wait for content directly. + await page.getByTestId("global-agent-advanced-toggle").click(); + + // All three numeric inputs must be present for buzz-agent. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + await expect(page.getByTestId("numeric-context-limit-input")).toBeVisible(); + await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible(); +}); + +test("global_advanced_non_capable_runtime_hides_numeric_controls", async ({ + page, +}) => { + // Claude has no numeric tuning env vars (contextLimitEnvVar = null etc.). + // After selecting Claude, the Advanced section must show no numeric inputs. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "available", + command: "/usr/local/bin/claude-agent", + binary_path: "/usr/local/bin/claude-agent", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install via npm.", + install_instructions_url: "https://example.com", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", + auth_status: { status: "logged_in" }, + }, + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "claude", + }, + }); + + await openAiDefaultsSettings(page); + + await page.getByTestId("global-agent-advanced-toggle").click(); + + // No numeric inputs must render for a non-capable runtime. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + await expect(page.getByTestId("numeric-context-limit-input")).toHaveCount(0); + await expect(page.getByTestId("numeric-max-rounds-input")).toHaveCount(0); +}); + +test("goose_per_agent_advanced_max_tokens_shows_inherited_global_placeholder", async ({ + page, +}) => { + // Save GOOSE_MAX_TOKENS = 16384 in the global Agent Defaults settings via + // the UI, then open a Goose agent's edit dialog. The max-output-tokens input + // must show "Inherit (16384)" — the globally-saved value surfaced via the + // inherited placeholder. + await installMockBridge(page, { + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" }, + provider: "anthropic", + model: "claude-opus-4-5", + preferred_runtime: "goose", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "goose", + status: "stopped", + channelNames: ["agents"], + }, + ], + }); + + // Step 1: open global defaults, expand Advanced, enter the max-tokens value. + await openAiDefaultsSettings(page); + await page.getByTestId("global-agent-advanced-toggle").click(); + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + await page.getByTestId("numeric-max-output-tokens-input").click(); + await page + .getByTestId("numeric-max-output-tokens-input") + .pressSequentially("16384"); + // Blur to ensure React's change event fires for the number input. + await page.keyboard.press("Tab"); + + // Step 2: save the global defaults. + await expect(page.getByRole("button", { name: "Save defaults" })).toBeEnabled( + { timeout: 5_000 }, + ); + await page.getByRole("button", { name: "Save defaults" }).click(); + // Wait for the save to complete: the button returns to disabled (dirty resets). + await expect( + page.getByRole("button", { name: "Save defaults" }), + ).toBeDisabled({ timeout: 5_000 }); + + // Step 3: navigate back and open the per-agent edit dialog for the Goose + // agent. We use the app's Back link rather than page.goto("/") to preserve + // the in-memory mock state (page.goto causes a full reload that resets it). + await page.getByRole("button", { name: "Back to app" }).click(); + await page.getByTestId("open-agents-view").click(); + const agentButton = page.getByRole("button", { + name: "Tyler Agent agent profile", + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); + + // Wait for the provider field — signals the catalog and dialog have settled. + await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({ + timeout: 10_000, + }); + + // Open the Advanced section. + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + + // The placeholder must reflect the globally-saved value. + await expect( + page.getByTestId("numeric-max-output-tokens-input"), + ).toHaveAttribute("placeholder", "Inherit (16384)"); +}); + +test("delayed_catalog_per_agent_saved_tuning_values_visible_then_structured_controls_appear", async ({ + page, +}) => { + // Scenario: catalog takes 5 seconds to respond (simulates slow discovery). + // The per-agent edit dialog opens. While the catalog is still in flight, + // saved tuning env vars must not be dropped from view — they appear as + // generic env rows (no hiddenKeys applied yet). Once the catalog settles, + // the structured numeric controls replace the generic rows. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: null, + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + auth_status: { status: "not_applicable" }, + }, + ], + // 5-second delay: generous enough that the dialog opens and Advanced is + // expanded while the catalog query is still in-flight (navigation takes + // ~1-2 s), but short enough to keep the test under 30 s. + acpRuntimesDelayMs: 5000, + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "buzz-agent", + status: "stopped", + channelNames: ["agents"], + envVars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "4096", + BUZZ_AGENT_MAX_ROUNDS: "25", + }, + }, + ], + }); + + await openEditAgentDialog(page, "Tyler Agent"); + + // Open Advanced before the catalog has settled (the dialog opens quickly; + // the catalog query fires when the dialog opens and takes ~5 seconds). + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + + // While loading: structured numeric controls must NOT be visible yet — + // the catalog-settling gate withholds them. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + + // The saved tuning env vars must be visible as generic rows (not hidden) + // while the catalog hasn't settled: BUZZ_AGENT_MAX_OUTPUT_TOKENS and + // BUZZ_AGENT_MAX_ROUNDS should appear in the env-vars editor. + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]', + ), + ).toBeVisible(); + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]', + ), + ).toBeVisible(); + + // After the catalog settles (allow up to 8 s — 5 s delay + margin): + // structured controls appear, replacing the generic rows. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 8_000 }, + ); + await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible({ + timeout: 8_000, + }); +}); + +test("failed_catalog_per_agent_saved_tuning_values_remain_visible_as_generic_rows", async ({ + page, +}) => { + // Scenario: catalog discovery fails (network error / IPC rejection). + // The per-agent edit dialog opens. The saved tuning env vars must remain + // visible as generic rows — the error state must never produce the + // "unsupported" no-controls state that would hide persisted values. + await installMockBridge(page, { + acpRuntimesError: true, + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "buzz-agent", + status: "stopped", + channelNames: ["agents"], + envVars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192", + BUZZ_AGENT_MAX_ROUNDS: "10", + }, + }, + ], + }); + + await openEditAgentDialog(page, "Tyler Agent"); + + // Open Advanced after a brief wait (query has had time to fail). + await page.waitForTimeout(500); + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + + // Structured numeric controls must NOT render (catalog errored — no runtime). + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + + // Saved tuning values must still be visible as generic env rows — the error + // state must never hide persisted values with no editor to replace them. + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]', + ), + ).toBeVisible({ timeout: 5_000 }); + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]', + ), + ).toBeVisible(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 830a82879a..45766d25b6 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; +import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -43,24 +44,6 @@ type MockCommandAvailability = { resolvedPath?: string | null; }; -type MockManagedAgentSeed = { - pubkey: string; - name: string; - personaId?: string | null; - status?: "running" | "stopped" | "deployed" | "not_deployed"; - channelNames?: string[]; - channelIds?: string[]; - backend?: - | { type: "local" } - | { type: "provider"; id: string; config: Record }; - lastError?: string | null; - lastErrorCode?: number | null; - needsRestart?: boolean; - autoRestartOnConfigChange?: boolean; - respondTo?: "owner-only" | "allowlist" | "anyone"; - respondToAllowlist?: string[]; -}; - type MockSearchProfileSeed = { pubkey: string; displayName: string | null; @@ -199,6 +182,8 @@ type MockBridgeOptions = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: Record[][]; acpRuntimesDelayMs?: number; + /** When true, the mock catalog discovery command throws an error. */ + acpRuntimesError?: boolean; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */ From ede8d22dd5b336f146e0a6d760fd9dff78a42613 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 16:23:55 -0700 Subject: [PATCH 008/134] feat(mobile): bring channel menus to desktop parity (#3940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview **Category:** improvement **User Impact:** Mobile users can now access consistent channel and DM actions from both the channel list and conversation header. **Problem:** Mobile channel menus exposed a narrower, inconsistent set of actions than desktop, and the available actions differed by entry point. **Solution:** This change introduces one reusable action sheet with a clear quick-action hierarchy, role-aware lifecycle controls, confirmations for consequential actions, and a deliberately narrower DM menu. ## Changes
File changes **mobile/lib/features/channels/channel_actions_sheet.dart** Adds the shared channel and DM action-sheet experience used by both entry points, including Star/Unstar and Read/Unread quick actions for channels, section movement, mute, management, inline copy actions, guarded lifecycle actions, confirmations, and a compact DM menu without quick actions. **mobile/lib/features/channels/channel_detail_page.dart** Routes the header ellipsis through the shared action sheet so the in-channel menu matches the channel-list experience, including for DMs. **mobile/lib/features/channels/channel_management_provider.dart** Adds archive and delete operations using the desktop-compatible relay event kinds and refreshes channel state after completion. **mobile/lib/features/channels/channels_page.dart** Makes the shared channel action-sheet entry point available to the channel-list implementation. **mobile/lib/features/channels/channels_page/channel_tile.dart** Replaces the tile-specific long-press menu with the reusable action sheet while preserving read state and section context. **mobile/test/features/channels/channel_actions_sheet_test.dart** Covers action hierarchy, owner/admin/member capability guards, loading and failure states, DM narrowing with no quick-action row, and inline copy actions. **mobile/test/features/channels/channel_detail_page_test.dart** Updates channel-header flows to exercise management through the new shared action sheet. **mobile/test/features/channels/channel_management_provider_test.dart** Verifies archive and delete event tags stay compatible with desktop behavior.
## Reproduction Steps 1. Run the mobile app and open a populated channel list. 2. Long-press a regular channel and verify the Star/Unstar and Read/Unread quick actions appear above Move to section…, Mute, Manage, Copy channel name, and Copy channel ID. 3. Choose either copy action and verify it copies the expected value. 4. Open a channel, tap the header ellipsis, and verify the same action sheet appears. 5. As an admin or owner, verify Archive appears; as an owner, verify Delete also appears. Confirm that lifecycle actions require confirmation. 6. Long-press or open the header menu for a DM and verify it has no quick-action row and starts with Mute, followed by Copy channel name and Copy channel ID. ## Screenshots ### Channel menu | Regular channel — Mark Unread | DM — no quick actions | Archive confirmation | |---|---|---| | ![Regular channel actions with Mark Unread](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-regular-channel-mark-unread.png) | ![DM actions without quick actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-dm-no-quick-actions.png) | ![Archive confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-archive-confirmation.png) | --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../channels/channel_actions_sheet.dart | 559 ++++++++++++++++++ .../channels/channel_detail_page.dart | 43 +- .../channels/channel_management_provider.dart | 44 ++ .../lib/features/channels/channels_page.dart | 1 + .../channels/channels_page/channel_tile.dart | 210 +------ .../channels/channel_actions_sheet_test.dart | 390 ++++++++++++ .../channels/channel_detail_page_test.dart | 45 +- .../channel_management_provider_test.dart | 22 + 8 files changed, 1082 insertions(+), 232 deletions(-) create mode 100644 mobile/lib/features/channels/channel_actions_sheet.dart create mode 100644 mobile/test/features/channels/channel_actions_sheet_test.dart diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart new file mode 100644 index 0000000000..843ee992e6 --- /dev/null +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -0,0 +1,559 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/clipboard_utils.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/sheet_divider.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; +import 'channel_mutes/channel_mutes_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; +import 'channel_stars/channel_stars_provider.dart'; +import 'channels_provider.dart'; +import 'manage_channel_sheet.dart'; +import 'read_state/read_state_provider.dart'; +import 'read_state/read_state_time.dart'; + +/// Opens the mobile channel actions sheet and returns whether its parent page +/// should close after a successful lifecycle action. +Future showChannelActionsSheet({ + required BuildContext context, + required Channel channel, + required bool isUnread, + VoidCallback? onMarkRead, + String? sectionId, +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + builder: (_) => ChannelActionsSheet( + channel: channel, + isUnread: isUnread, + onMarkRead: onMarkRead, + sectionId: sectionId, + ), +); + +/// Mobile action sheet for channel-level read, organization, and lifecycle +/// operations. +class ChannelActionsSheet extends ConsumerWidget { + const ChannelActionsSheet({ + super.key, + required this.channel, + required this.isUnread, + this.onMarkRead, + this.sectionId, + }); + + final Channel channel; + final bool isUnread; + final VoidCallback? onMarkRead; + final String? sectionId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMuted = + ref.watch(channelMutesProvider).store.channels[channel.id]?.muted == + true; + final isStarred = + !channel.isDm && + ref.watch(channelStarsProvider).store.channels[channel.id]?.starred == + true; + final membersAsync = channel.isDm + ? const AsyncValue>.data([]) + : ref.watch(channelMembersProvider(channel.id)); + final agentOwnersAsync = channel.isDm + ? const AsyncValue>.data({}) + : ref.watch(agentOwnersProvider); + final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); + final currentMember = membersAsync.value?.cast().firstWhere( + (member) => member?.pubkey.toLowerCase() == currentPubkey, + orElse: () => null, + ); + final ownsOwnerAgent = + currentPubkey != null && + membersAsync.value?.any( + (member) => + member.isOwner && + agentOwnersAsync.value?[member.pubkey.toLowerCase()] + ?.toLowerCase() == + currentPubkey, + ) == + true; + final canManageLifecycle = + currentMember?.isElevated == true || ownsOwnerAgent; + final canArchive = !channel.isArchived && canManageLifecycle; + final canUnarchive = channel.isArchived && canManageLifecycle; + final canDelete = + !channel.isArchived && + (currentMember?.isOwner == true || ownsOwnerAgent); + final lifecycleCapabilitiesLoading = + membersAsync.isLoading || agentOwnersAsync.isLoading; + final lifecycleCapabilitiesUnavailable = + membersAsync.hasError || agentOwnersAsync.hasError; + + void close() => Navigator.of(context).pop(); + + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!channel.isDm) ...[ + _ChannelQuickActionsRow( + isStarred: isStarred, + isUnread: isUnread, + onToggleStar: () { + close(); + final notifier = ref.read(channelStarsProvider.notifier); + isStarred + ? notifier.unstarChannel(channel.id) + : notifier.starChannel(channel.id); + }, + onToggleRead: () { + close(); + final timestamp = dateTimeToUnixSeconds( + channel.lastMessageAt, + ); + if (isUnread) { + onMarkRead?.call(); + if (timestamp != null) { + ref + .read(readStateProvider.notifier) + .markContextRead( + channel.id, + timestamp, + clearForcedMessages: true, + ); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead( + channel.id, + timestamp, + ); + } + } else { + ref + .read(readStateProvider.notifier) + .markContextUnread(channel.id, channelId: channel.id); + } + }, + ), + const SizedBox(height: Grid.xs), + ], + if (!channel.isDm) + ListTile( + leading: const Icon(LucideIcons.folderInput), + title: const Text('Move to section…'), + onTap: () async { + final pageContext = Navigator.of( + context, + rootNavigator: true, + ).context; + close(); + await _showMoveSectionSheet( + pageContext, + ref, + channel: channel, + sectionId: sectionId, + ); + }, + ), + ListTile( + leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff), + title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), + onTap: () { + close(); + final notifier = ref.read(channelMutesProvider.notifier); + isMuted + ? notifier.unmuteChannel(channel.id) + : notifier.muteChannel(channel.id); + }, + ), + if (!channel.isDm) + ListTile( + leading: const Icon(LucideIcons.settings), + title: const Text('Manage channel'), + onTap: () async { + final shouldClose = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => ManageChannelSheet(channel: channel), + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy channel name'), + onTap: () { + close(); + copyToClipboard( + context, + channel.name, + message: 'Channel name copied to clipboard', + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.hash), + title: const Text('Copy channel ID'), + onTap: () { + close(); + copyToClipboard( + context, + channel.id, + message: 'Channel ID copied to clipboard', + ); + }, + ), + if (!channel.isDm) ...[ + const SheetDivider(), + if (channel.isMember && !channel.isArchived) + _ActionTile( + icon: LucideIcons.logOut, + label: 'Leave channel', + destructive: true, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Leave #${channel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', + action: () => ref + .read(channelActionsProvider) + .leaveChannel(channel.id), + ), + ), + if (lifecycleCapabilitiesLoading) + const ListTile( + enabled: false, + leading: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + title: Text('Loading channel actions…'), + ) + else if (lifecycleCapabilitiesUnavailable) + const ListTile( + enabled: false, + leading: Icon(LucideIcons.triangleAlert), + title: Text('Channel actions unavailable'), + ) + else ...[ + if (canArchive) + _ActionTile( + icon: LucideIcons.archive, + label: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${channel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(channel.id), + ), + ), + if (canUnarchive) + _ActionTile( + icon: LucideIcons.archiveRestore, + label: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${channel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(channel.id), + ), + ), + if (canDelete) + _ActionTile( + icon: LucideIcons.trash2, + label: 'Delete channel', + destructive: true, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${channel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(channel.id), + ), + ), + ], + ], + ], + ), + ), + ); + } +} + +class _ChannelQuickActionsRow extends StatelessWidget { + const _ChannelQuickActionsRow({ + required this.isStarred, + required this.isUnread, + required this.onToggleStar, + required this.onToggleRead, + }); + + final bool isStarred; + final bool isUnread; + final VoidCallback onToggleStar; + final VoidCallback onToggleRead; + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ChannelQuickAction( + icon: isStarred ? LucideIcons.starOff : LucideIcons.star, + label: isStarred ? 'Unstar' : 'Star', + onTap: onToggleStar, + ), + _ChannelQuickAction( + icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, + label: isUnread ? 'Mark Read' : 'Mark Unread', + onTap: onToggleRead, + ), + ], + ); +} + +class _ChannelQuickAction extends StatelessWidget { + const _ChannelQuickAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 76, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + child: Icon(icon, size: 24, color: context.colors.onSurface), + ), + const SizedBox(height: Grid.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ); +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(icon, color: destructive ? context.colors.error : null), + title: Text( + label, + style: destructive ? TextStyle(color: context.colors.error) : null, + ), + onTap: onTap, + ); +} + +Future _confirmAndRun( + BuildContext sheetContext, + WidgetRef ref, { + required String title, + required String body, + required String confirmLabel, + required Future Function() action, +}) async { + final pageContext = Navigator.of(sheetContext, rootNavigator: true).context; + final confirmed = await showDialog( + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(confirmLabel), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await action(); + if (pageContext.mounted) Navigator.of(pageContext).pop(true); + } catch (error) { + if (!pageContext.mounted) return; + ScaffoldMessenger.of(pageContext).showSnackBar( + SnackBar( + content: Text( + 'Couldn’t ${confirmLabel.toLowerCase()} channel. Try again.', + ), + ), + ); + } +} + +Future _showMoveSectionSheet( + BuildContext context, + WidgetRef ref, { + required Channel channel, + required String? sectionId, +}) async { + final sections = [...ref.read(channelSectionsProvider).store.sections] + ..sort((a, b) => a.order.compareTo(b.order)); + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final section in sections) + ListTile( + leading: const Icon(LucideIcons.folder), + title: Text(section.name), + trailing: sectionId == section.id + ? Icon( + LucideIcons.check, + color: sheetContext.colors.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .assignChannel(channel.id, section.id); + }, + ), + ListTile( + leading: const Icon(LucideIcons.folderPlus), + title: const Text('New section…'), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _showSectionNameDialog(context); + if (name == null || name.isEmpty) return; + final notifier = ref.read(channelSectionsProvider.notifier); + notifier.createSection(name); + final created = ref + .read(channelSectionsProvider) + .store + .sections + .where((section) => section.name == name.trim()) + .lastOrNull; + if (created != null) { + notifier.assignChannel(channel.id, created.id); + } + }, + ), + if (sectionId != null) + ListTile( + leading: const Icon(LucideIcons.folderMinus), + title: const Text('Remove from section'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .unassignChannel(channel.id); + }, + ), + ], + ), + ), + ), + ); +} + +Future _showSectionNameDialog(BuildContext context) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('New Section'), + content: TextField(controller: controller, autofocus: true), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(controller.text.trim()), + child: const Text('Create'), + ), + ], + ), + ); + controller.dispose(); + return result; +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f1efb1557f..690e50905f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -25,9 +25,11 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; import 'channel.dart'; +import 'channel_actions_sheet.dart'; import 'channel_link_navigation.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; @@ -38,7 +40,6 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; -import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; @@ -277,27 +278,25 @@ class ChannelDetailPage extends HookConsumerWidget { channel: resolvedChannel, currentPubkey: currentPubkey, ), - if (!resolvedChannel.isDm) - IconButton( - color: context.colors.primary, - onPressed: () async { - final shouldClose = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: MediaQuery.sizeOf(context).height * 0.9, - ), - builder: (_) => ManageChannelSheet(channel: resolvedChannel), - ); - if (shouldClose == true && context.mounted) { - Navigator.of(context).pop(); - } - }, - tooltip: 'Manage channel', - icon: const Icon(LucideIcons.ellipsisVertical, size: 22), - ), + IconButton( + color: context.colors.primary, + onPressed: () async { + final shouldClose = await showChannelActionsSheet( + context: context, + channel: resolvedChannel, + isUnread: false, + sectionId: ref + .read(channelSectionsProvider) + .store + .assignments[resolvedChannel.id], + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(); + } + }, + tooltip: 'Channel actions', + icon: const Icon(LucideIcons.ellipsisVertical, size: 22), + ), ], ), body: Stack( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index b990194d15..ed9f6842f7 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -479,6 +479,20 @@ List> buildCreateChannelTags({ ]; } +/// Builds the relay tags for setting the archived state of [channelId]. +List> buildSetChannelArchivedTags( + String channelId, { + required bool archived, +}) => [ + ['h', channelId], + ['archived', archived.toString()], +]; + +/// Builds the relay tags for deleting [channelId]. +List> buildDeleteChannelTags(String channelId) => [ + ['h', channelId], +]; + class ChannelActions { final Ref _ref; final RelaySessionNotifier _session; @@ -584,6 +598,36 @@ class ChannelActions { await _refreshChannelState(channelId); } + /// Archives the channel and refreshes its cached state. + Future archiveChannel(String channelId) => + _setChannelArchived(channelId, archived: true); + + /// Unarchives the channel and refreshes its cached state. + Future unarchiveChannel(String channelId) => + _setChannelArchived(channelId, archived: false); + + Future _setChannelArchived( + String channelId, { + required bool archived, + }) async { + await _signedEventRelay.submit( + kind: 9002, + content: '', + tags: buildSetChannelArchivedTags(channelId, archived: archived), + ); + await _refreshChannelState(channelId); + } + + /// Deletes the channel and refreshes its cached state. + Future deleteChannel(String channelId) async { + await _signedEventRelay.submit( + kind: 9008, + content: '', + tags: buildDeleteChannelTags(channelId), + ); + await _refreshChannelState(channelId); + } + Future setCanvas({ required String channelId, required String content, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9d..607667e284 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -29,6 +29,7 @@ import '../profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; +import 'channel_actions_sheet.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; import 'dm_channel_labels.dart'; diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 551e3f8dd6..95d0b2c0be 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -118,212 +118,12 @@ class _ChannelTile extends ConsumerWidget { } void _showChannelActions(BuildContext context, WidgetRef ref) { - showModalBottomSheet( + showChannelActionsSheet( context: context, - showDragHandle: true, - builder: (sheetContext) { - final sections = ref.read(channelSectionsProvider).store.sections - ..sort((a, b) => a.order.compareTo(b.order)); - final isStarred = - ref - .read(channelStarsProvider) - .store - .channels[channel.id] - ?.starred == - true; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - isStarred ? LucideIcons.starOff : LucideIcons.star, - ), - title: Text(isStarred ? 'Unstar channel' : 'Star channel'), - onTap: () { - Navigator.of(sheetContext).pop(); - if (isStarred) { - ref - .read(channelStarsProvider.notifier) - .unstarChannel(channel.id); - } else { - ref - .read(channelStarsProvider.notifier) - .starChannel(channel.id); - } - }, - ), - ListTile( - leading: const Icon(LucideIcons.folderInput), - title: const Text('Move to section'), - onTap: () async { - Navigator.of(sheetContext).pop(); - await _showMoveSectionSheet(context, ref, sections); - }, - ), - ListTile( - leading: Icon( - isMuted ? LucideIcons.bell : LucideIcons.bellOff, - ), - title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), - onTap: () { - Navigator.of(sheetContext).pop(); - if (isMuted) { - ref - .read(channelMutesProvider.notifier) - .unmuteChannel(channel.id); - } else { - ref - .read(channelMutesProvider.notifier) - .muteChannel(channel.id); - } - }, - ), - ListTile( - leading: Icon( - isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, - ), - title: Text(isUnread ? 'Mark as read' : 'Mark as unread'), - onTap: () { - Navigator.of(sheetContext).pop(); - final ts = dateTimeToUnixSeconds(channel.lastMessageAt); - if (ts != null) { - if (isUnread) { - onMarkRead?.call(); - ref - .read(readStateProvider.notifier) - .markContextRead( - channel.id, - ts, - clearForcedMessages: true, - ); - ref - .read(channelsProvider.notifier) - .clearObservedUnreadCoveredByRead(channel.id, ts); - } else { - ref - .read(readStateProvider.notifier) - .markContextUnread( - channel.id, - channelId: channel.id, - ); - } - } - }, - ), - ], - ), - ), - ); - }, - ); - } - - Future _showMoveSectionSheet( - BuildContext context, - WidgetRef ref, - List sections, - ) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final section in sections) - ListTile( - leading: Icon( - LucideIcons.folder, - color: sectionId == section.id - ? sheetContext.colors.primary - : null, - ), - title: Text(section.name), - trailing: sectionId == section.id - ? Icon( - LucideIcons.check, - color: sheetContext.colors.primary, - ) - : null, - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .assignChannel(channel.id, section.id); - }, - ), - ListTile( - leading: const Icon(LucideIcons.folderPlus), - title: const Text('New section…'), - onTap: () async { - Navigator.of(sheetContext).pop(); - if (!context.mounted) return; - final name = await showDialog( - context: context, - builder: (_) => const _SectionNameDialog( - title: 'New Section', - confirmLabel: 'Create', - ), - ); - if (name != null && name.isNotEmpty) { - ref - .read(channelSectionsProvider.notifier) - .createSection(name); - // Assign after create — sections list has been mutated, - // re-read to find the new section by name. - final newSection = ref - .read(channelSectionsProvider) - .store - .sections - .lastWhere( - (s) => s.name == name.trim(), - orElse: () => const ChannelSection( - id: '', - name: '', - order: -1, - ), - ); - if (newSection.id.isNotEmpty) { - ref - .read(channelSectionsProvider.notifier) - .assignChannel(channel.id, newSection.id); - } - } - }, - ), - if (sectionId != null) - ListTile( - leading: const Icon(LucideIcons.folderMinus), - title: const Text('Remove from section'), - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .unassignChannel(channel.id); - }, - ), - ], - ), - ), - ); - }, + channel: channel, + isUnread: isUnread, + onMarkRead: onMarkRead, + sectionId: sectionId, ); } } diff --git a/mobile/test/features/channels/channel_actions_sheet_test.dart b/mobile/test/features/channels/channel_actions_sheet_test.dart new file mode 100644 index 0000000000..e38a40d9a5 --- /dev/null +++ b/mobile/test/features/channels/channel_actions_sheet_test.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_actions_sheet.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const _currentPubkey = 'me'; + +Channel _channel({String type = 'stream', bool isArchived = false}) => Channel( + id: 'channel-id', + name: type == 'dm' ? 'Alice' : 'general', + channelType: type, + visibility: 'open', + description: '', + createdBy: 'owner', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + archivedAt: isArchived ? DateTime(2025, 1, 2) : null, +); + +Widget _app({ + required Channel channel, + required Future> Function() loadMembers, + bool isUnread = false, + AsyncValue> agentOwners = const AsyncValue.data( + {}, + ), + ChannelActions Function(Ref ref)? createChannelActions, + String? currentPubkey = _currentPubkey, +}) => ProviderScope( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => currentPubkey), + channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()), + agentOwnersProvider.overrideWithValue(agentOwners), + if (createChannelActions != null) + channelActionsProvider.overrideWith(createChannelActions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: ChannelActionsSheet(channel: channel, isUnread: isUnread), + ), + ), +); + +Widget _modalApp({ + required Channel channel, + required Future> Function() loadMembers, + required ChannelActions Function(Ref ref) createChannelActions, +}) => ProviderScope( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => _currentPubkey), + channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()), + channelActionsProvider.overrideWith(createChannelActions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showChannelActionsSheet( + context: context, + channel: channel, + isUnread: false, + ), + child: const Text('Open actions'), + ), + ), + ), + ), +); + +void main() { + testWidgets('owner sees the complete regular-channel action set', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + for (final label in [ + 'Star', + 'Mark Unread', + 'Move to section…', + 'Mute channel', + 'Manage channel', + 'Copy channel name', + 'Copy channel ID', + 'Leave channel', + 'Archive channel', + 'Delete channel', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + + final moveTop = tester.getTopLeft(find.text('Move to section…')).dy; + final muteTop = tester.getTopLeft(find.text('Mute channel')).dy; + final manageTop = tester.getTopLeft(find.text('Manage channel')).dy; + final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy; + final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy; + expect(moveTop, lessThan(muteTop)); + expect(muteTop, lessThan(manageTop)); + expect(manageTop, lessThan(copyNameTop)); + expect(copyNameTop, lessThan(copyIdTop)); + }); + + testWidgets('unread channel uses the Mark Read label', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + isUnread: true, + loadMembers: () async => const [], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Mark Read'), findsOneWidget); + expect(find.text('Mark Unread'), findsNothing); + }); + + testWidgets('admin can archive but cannot delete', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'admin', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('verified owner agent grants archive and delete', (tester) async { + const agentPubkey = 'agent'; + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}), + loadMembers: () async => [ + ChannelMember( + pubkey: agentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsOneWidget); + }); + + testWidgets('unresolved identity grants no lifecycle actions', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + currentPubkey: null, + loadMembers: () async => [ + ChannelMember( + pubkey: 'ordinary-owner', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('archived owner can unarchive but cannot delete', (tester) async { + late _FakeChannelActions actions; + await tester.pumpWidget( + _app( + channel: _channel(isArchived: true), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => actions = _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Unarchive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsNothing); + + await tester.tap(find.text('Unarchive channel')); + await tester.pumpAndSettle(); + expect(find.text('Unarchive #general?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Unarchive')); + await tester.pumpAndSettle(); + + expect(actions.unarchivedChannelId, 'channel-id'); + }); + + testWidgets('owned non-owner agent grants no lifecycle actions', ( + tester, + ) async { + const agentPubkey = 'agent'; + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}), + loadMembers: () async => [ + ChannelMember( + pubkey: agentPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('agent ownership loading keeps lifecycle actions pending', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.loading(), + loadMembers: () async => const [], + ), + ); + await tester.pump(); + + expect(find.text('Loading channel actions…'), findsOneWidget); + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('member sees neither owner action', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + expect(find.text('Leave channel'), findsOneWidget); + }); + + testWidgets('shows loading and unavailable capability states', ( + tester, + ) async { + final pending = Completer>(); + await tester.pumpWidget( + _app(channel: _channel(), loadMembers: () => pending.future), + ); + await tester.pump(); + expect(find.text('Loading channel actions…'), findsOneWidget); + + pending.completeError(Exception('relay unavailable')); + await tester.pumpAndSettle(); + expect(find.text('Channel actions unavailable'), findsOneWidget); + }); + + testWidgets( + 'manage leave closes both nested sheets without popping the page', + (tester) async { + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => const [], + createChannelActions: (ref) => _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave channel').last); + await tester.pumpAndSettle(); + + expect(find.byType(ChannelActionsSheet), findsNothing); + expect(find.byType(Scaffold), findsOneWidget); + }, + ); + + testWidgets('DM omits quick actions, then shows mute and copy rows', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(type: 'dm'), + loadMembers: () async => const [], + ), + ); + await tester.pumpAndSettle(); + + for (final label in [ + 'Mute channel', + 'Copy channel name', + 'Copy channel ID', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + for (final label in [ + 'Star', + 'Unstar', + 'Mark Unread', + 'Mark Read', + 'Move to section…', + 'Manage channel', + 'Leave channel', + 'Archive channel', + 'Delete channel', + ]) { + expect(find.text(label), findsNothing, reason: label); + } + + final muteTop = tester.getTopLeft(find.text('Mute channel')).dy; + final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy; + final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy; + expect(muteTop, lessThan(copyNameTop)); + expect(copyNameTop, lessThan(copyIdTop)); + }); +} + +class _FakeChannelActions extends ChannelActions { + _FakeChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: _currentPubkey, + ); + + String? unarchivedChannelId; + + @override + Future leaveChannel(String channelId) async {} + + @override + Future unarchiveChannel(String channelId) async { + unarchivedChannelId = channelId; + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7f8d0774c3..4abf00f1e4 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -828,7 +828,14 @@ void main() { ); expect(find.text('Message…'), findsNothing); - await tester.tap(find.byTooltip('Manage channel')); + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).last, + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); await tester.pumpAndSettle(); await tester.tap(find.text('Join channel')); await tester.pumpAndSettle(); @@ -841,6 +848,27 @@ void main() { expect(find.text('Message #general'), findsOneWidget); }); + testWidgets('detail-header manage leave closes the detail page', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + createChannelActions: (ref) => _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave channel').last); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Channel actions'), findsNothing); + }); + testWidgets('keeps manage sheet dismissible with a long canvas', ( tester, ) async { @@ -860,11 +888,18 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Manage channel')); + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).last, + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); await tester.pumpAndSettle(); - final sheet = find.byType(BottomSheet); - expect(sheet, findsOneWidget); + final sheet = find.byType(BottomSheet).last; + expect(find.byType(BottomSheet), findsNWidgets(2)); expect(tester.getSize(sheet).height, lessThanOrEqualTo(720)); final sheetTop = tester.getTopLeft(sheet).dy; @@ -874,7 +909,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(sheet, findsNothing); + expect(find.text('Manage channel'), findsOneWidget); }); testWidgets('shows empty state when no messages', (tester) async { diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 4b31de18e8..89659f37e4 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -200,6 +200,28 @@ void main() { }); }); + group('build channel lifecycle tags', () { + test('archive matches kind 9002 tags', () { + expect(buildSetChannelArchivedTags('channel-id', archived: true), [ + ['h', 'channel-id'], + ['archived', 'true'], + ]); + }); + + test('unarchive matches kind 9002 tags', () { + expect(buildSetChannelArchivedTags('channel-id', archived: false), [ + ['h', 'channel-id'], + ['archived', 'false'], + ]); + }); + + test('delete matches desktop kind 9008 tags', () { + expect(buildDeleteChannelTags('channel-id'), [ + ['h', 'channel-id'], + ]); + }); + }); + group('directory providers relay-config invalidation', () { NostrEvent profile(String pubkey, String name) => NostrEvent( id: '$pubkey-profile', From b29c8cdaa456307ecdd63e565de4beb14402128e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 01:16:09 +0100 Subject: [PATCH 009/134] feat(desktop): redesign the Huddle experience (#4281) ## Summary - open Huddles in a focused companion window with a clean handoff back to the in-app drawer and backing channel - redesign the participant film strip, sidebar control, transcript surface, and themed shell treatment - preserve microphone and device control across windows, start agent voice on the first reply, and show agent speaking activity in the film strip - give each agent a distinct session voice, beginning with the configured default, plus compact per-agent text-to-speech and voice controls - enroll only agents explicitly mentioned or deliberately added through an agent panel into the live Huddle roster - keep temporary Huddle channels out of the sidebar unless the user explicitly brings one into the main app - remove Huddle-only avatar policy badges and filter short silence or noise segments before speech-to-text posts ## Why The previous flow exposed the temporary channel as product UI, obscured who was present or speaking, and split transcript and audio state between the main and companion windows. This keeps backing channels as implementation details unless a user explicitly brings a Huddle into the app, while sharing the live conversation and audio lifecycle across both surfaces. Agent participants now join only after an explicit invitation, distinct voices make multi-agent Huddles easier to follow, and short microphone noise no longer becomes stray transcript messages. ## Validation - `pnpm check` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke` (13 passed) - Huddle sidebar visibility unit coverage (4 passed) - focused managed-agent and persona-mention E2E coverage (2 passed) - `pnpm test` (3,910 passed) - `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` (2,093 passed, 14 ignored; 3 diagnostics passed) --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/src/huddle/agent_voice.rs | 310 +++++ desktop/src-tauri/src/huddle/agents.rs | 177 ++- desktop/src-tauri/src/huddle/mod.rs | 77 +- desktop/src-tauri/src/huddle/pipeline.rs | 82 +- desktop/src-tauri/src/huddle/playout.rs | 121 +- desktop/src-tauri/src/huddle/state.rs | 16 +- desktop/src-tauri/src/huddle/stt.rs | 121 +- desktop/src-tauri/src/huddle/tts.rs | 123 +- desktop/src-tauri/src/huddle/tts_activity.rs | 45 + desktop/src-tauri/src/huddle/tts_settings.rs | 3 +- desktop/src-tauri/src/huddle/tts_tests.rs | 31 +- .../src/huddle/tts_voice_selection_tests.rs | 20 +- .../src/huddle/tts_voice_transition.rs | 58 +- desktop/src-tauri/src/huddle/window.rs | 67 ++ desktop/src-tauri/src/initial_window.rs | 67 ++ desktop/src-tauri/src/lib.rs | 111 +- desktop/src/app/App.tsx | 14 +- desktop/src/app/AppHuddleBar.tsx | 6 +- desktop/src/app/AppHuddleShell.tsx | 76 ++ desktop/src/app/AppShell.tsx | 580 ++++----- desktop/src/app/AppShellChannelSurface.tsx | 43 + desktop/src/app/BuzzThemeSurfaces.tsx | 17 +- desktop/src/app/LazySettingsScreen.tsx | 6 + .../app/huddleBackingChannelStorage.test.mjs | 33 + .../src/app/huddleBackingChannelStorage.ts | 36 + .../src/app/huddleChannelVisibility.test.mjs | 65 + desktop/src/app/huddleChannelVisibility.ts | 19 + .../src/app/navigation/useAppNavigation.ts | 3 + desktop/src/app/routes/ChannelRouteScreen.tsx | 6 + .../src/app/routes/channels.$channelId.tsx | 11 +- .../app/useAppShellDesktopNotifications.ts | 8 +- .../src/app/useAppShellLifecycleEffects.ts | 7 + desktop/src/app/useHuddlePresentation.ts | 444 +++++++ desktop/src/app/useSettingsShortcuts.ts | 4 +- desktop/src/features/agents/hooks.ts | 7 + desktop/src/features/channels/hooks.ts | 16 + .../channels/ui/ChannelMembersBar.tsx | 4 +- .../channels/ui/ChannelPane.helpers.ts | 14 + .../src/features/channels/ui/ChannelPane.tsx | 80 +- .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 82 +- .../ui/ChannelScreenLoadingFallback.tsx | 14 + .../channels/ui/useChannelPaneMessages.ts | 50 + .../channels/ui/useHuddleChannelMessages.ts | 72 ++ .../channels/ui/useHuddleReadMarker.ts | 89 ++ .../channels/ui/useHuddleThreadIsolation.ts | 24 + desktop/src/features/huddle/HuddleContext.tsx | 549 ++++++--- .../features/huddle/HuddleContext.types.ts | 40 + .../huddle/components/AgentVoiceMenu.tsx | 152 +++ .../huddle/components/HuddleAttachment.tsx | 26 +- .../features/huddle/components/HuddleBar.tsx | 346 +++--- .../components/HuddleProfileControl.tsx | 176 +++ .../huddle/components/HuddleRoomHeader.tsx | 126 ++ .../huddle/components/HuddleStartingView.tsx | 18 + .../components/HuddleTranscriptIntro.tsx | 22 + .../huddle/components/MicControls.tsx | 35 +- .../huddle/components/ParticipantList.tsx | 492 ++++++-- desktop/src/features/huddle/index.ts | 4 + .../src/features/huddle/lib/huddleWindow.ts | 23 + .../features/huddle/lib/ttsLiveMessages.ts | 16 +- .../features/huddle/lib/useAudioDevices.ts | 18 +- .../features/huddle/lib/useHuddlePttState.ts | 73 ++ .../huddle/lib/useHuddleSpeakerActivity.ts | 99 ++ .../features/huddle/lib/useTtsSubscription.ts | 138 ++- .../messages/lib/virtualizedTimelineItems.ts | 3 +- .../src/features/messages/ui/MessageRow.tsx | 11 +- .../messages/ui/MessageThreadPanel.tsx | 137 ++- .../features/messages/ui/MessageTimeline.tsx | 43 +- .../features/messages/ui/MessageTimestamp.tsx | 1 + .../messages/ui/TimelineMessageList.tsx | 46 +- .../messages/ui/useMentionSendFlow.ts | 18 + .../src/features/messages/useThreadReplies.ts | 94 +- desktop/src/features/notifications/hooks.ts | 2 + .../use-feed-desktop-notifications.ts | 4 +- .../onboarding/communityOnboarding.tsx | 15 +- .../src/features/sidebar/ui/AppSidebar.tsx | 24 +- .../features/sidebar/ui/AppSidebar.types.ts | 7 + .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/src/main.tsx | 3 +- .../shared/api/relayChannelFilters.test.mjs | 7 +- desktop/src/shared/api/relayChannelFilters.ts | 13 +- .../src/shared/styles/globals/components.css | 65 +- desktop/src/shared/styles/globals/theme.css | 46 +- desktop/src/shared/useMessageDeepLinks.ts | 6 +- desktop/src/testing/e2eBridge.ts | 351 +++++- desktop/tests/e2e/community-rail.spec.ts | 9 + .../tests/e2e/huddle-transcription.spec.ts | 1053 +++++++++++++++-- desktop/tests/helpers/bridge.ts | 9 + 89 files changed, 6260 insertions(+), 1327 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/agent_voice.rs create mode 100644 desktop/src-tauri/src/huddle/tts_activity.rs create mode 100644 desktop/src-tauri/src/huddle/window.rs create mode 100644 desktop/src-tauri/src/initial_window.rs create mode 100644 desktop/src/app/AppHuddleShell.tsx create mode 100644 desktop/src/app/AppShellChannelSurface.tsx create mode 100644 desktop/src/app/LazySettingsScreen.tsx create mode 100644 desktop/src/app/huddleBackingChannelStorage.test.mjs create mode 100644 desktop/src/app/huddleBackingChannelStorage.ts create mode 100644 desktop/src/app/huddleChannelVisibility.test.mjs create mode 100644 desktop/src/app/huddleChannelVisibility.ts create mode 100644 desktop/src/app/useHuddlePresentation.ts create mode 100644 desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx create mode 100644 desktop/src/features/channels/ui/useChannelPaneMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleChannelMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleReadMarker.ts create mode 100644 desktop/src/features/channels/ui/useHuddleThreadIsolation.ts create mode 100644 desktop/src/features/huddle/HuddleContext.types.ts create mode 100644 desktop/src/features/huddle/components/AgentVoiceMenu.tsx create mode 100644 desktop/src/features/huddle/components/HuddleProfileControl.tsx create mode 100644 desktop/src/features/huddle/components/HuddleRoomHeader.tsx create mode 100644 desktop/src/features/huddle/components/HuddleStartingView.tsx create mode 100644 desktop/src/features/huddle/components/HuddleTranscriptIntro.tsx create mode 100644 desktop/src/features/huddle/lib/huddleWindow.ts create mode 100644 desktop/src/features/huddle/lib/useHuddlePttState.ts create mode 100644 desktop/src/features/huddle/lib/useHuddleSpeakerActivity.ts create mode 100644 desktop/src/features/sidebar/ui/AppSidebar.types.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 8835b29dec..a2e09bcb33 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for the main window and trusted huddle companions", + "windows": ["main", "huddle-*"], "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", diff --git a/desktop/src-tauri/src/huddle/agent_voice.rs b/desktop/src-tauri/src/huddle/agent_voice.rs new file mode 100644 index 0000000000..5232287d75 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_voice.rs @@ -0,0 +1,310 @@ +//! Per-agent text-to-speech choices for one local huddle session. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; + +use super::{ + tts_settings::{ + pocket_voice_reference, resolve_voice_for_backend_in_registry, voice_registry, + VoiceRegistryEntry, POCKET_BACKEND_ID, + }, + HuddlePhase, HuddleState, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentVoiceSettings { + pub enabled: bool, + pub voice_key: String, +} + +struct AgentVoiceCatalog { + default_voice_key: String, + voices: Vec, +} + +fn catalog(app: &AppHandle, state: &AppState) -> Result { + let registry = voice_registry(app); + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let voices: Vec<_> = registry + .iter() + .filter(|voice| { + voice.backend == POCKET_BACKEND_ID + && matches!(voice.availability.as_str(), "bundled" | "installed") + }) + .cloned() + .collect(); + let default_voice_key = resolve_voice_for_backend_in_registry( + &settings.voice_preferences, + POCKET_BACKEND_ID, + &voices, + )? + .key; + Ok(AgentVoiceCatalog { + default_voice_key, + voices, + }) +} + +fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> usize { + let hash = agent_pubkey.bytes().fold( + 0xcbf2_9ce4_8422_2325_u64 ^ huddle_generation, + |hash, byte| hash.wrapping_mul(0x0000_0100_0000_01b3) ^ u64::from(byte), + ); + (hash as usize) % len +} + +pub(crate) fn sync_agent_voice_assignments( + huddle: &mut HuddleState, + agent_pubkeys: &[String], + default_voice_key: &str, + voices: &[VoiceRegistryEntry], +) -> bool { + let previous = huddle.agent_voice_settings.clone(); + let available_keys: Vec<_> = voices.iter().map(|voice| voice.key.clone()).collect(); + let available: HashSet<_> = available_keys.iter().cloned().collect(); + let agents: HashSet<_> = agent_pubkeys.iter().cloned().collect(); + huddle.agent_voice_settings.retain(|pubkey, settings| { + agents.contains(pubkey) && available.contains(&settings.voice_key) + }); + + let mut used: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.clone()) + .collect(); + for (index, pubkey) in agent_pubkeys.iter().enumerate() { + if huddle.agent_voice_settings.contains_key(pubkey) { + continue; + } + let preferred = if index == 0 && !used.contains(default_voice_key) { + Some(default_voice_key.to_owned()) + } else { + let unused_alternates: Vec<_> = available_keys + .iter() + .filter(|key| key.as_str() != default_voice_key && !used.contains(*key)) + .cloned() + .collect(); + let unused: Vec<_> = available_keys + .iter() + .filter(|key| !used.contains(*key)) + .cloned() + .collect(); + let candidates = if unused_alternates.is_empty() { + if unused.is_empty() { + &available_keys + } else { + &unused + } + } else { + &unused_alternates + }; + (!candidates.is_empty()).then(|| { + candidates[stable_voice_index(pubkey, huddle.huddle_generation, candidates.len())] + .clone() + }) + }; + if let Some(voice_key) = preferred { + used.insert(voice_key.clone()); + huddle.agent_voice_settings.insert( + pubkey.clone(), + AgentVoiceSettings { + enabled: true, + voice_key, + }, + ); + } + } + huddle.agent_voice_settings != previous +} + +fn ensure_with_catalog( + huddle: &mut HuddleState, + catalog: &AgentVoiceCatalog, + extra_agent: Option<&str>, +) -> bool { + let mut agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if let Some(pubkey) = extra_agent { + if !agents.iter().any(|agent| agent == pubkey) { + agents.push(pubkey.to_owned()); + } + } + sync_agent_voice_assignments(huddle, &agents, &catalog.default_voice_key, &catalog.voices) +} + +fn require_active_huddle(huddle: &HuddleState) -> Result<(), String> { + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + .then_some(()) + .ok_or_else(|| "No active huddle".to_owned()) +} + +#[tauri::command] +pub fn ensure_huddle_agent_voice_settings( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let catalog = catalog(&app, &state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(BTreeMap::new()); + } + let changed = ensure_with_catalog(&mut huddle, &catalog, None); + (changed, huddle.agent_voice_settings.clone()) + }; + if changed { + state.emit_huddle_state_changed(); + } + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_tts_enabled( + agent_pubkey: String, + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.enabled = enabled; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_voice( + agent_pubkey: String, + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + if !catalog.voices.iter().any(|voice| voice.key == voice_key) { + return Err("The selected Pocket voice is not available on this device".to_owned()); + } + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.voice_key = voice_key; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +pub(crate) fn voice_reference_for_agent( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result, String> { + let catalog = catalog(app, state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + let changed = ensure_with_catalog(&mut huddle, &catalog, Some(agent_pubkey)); + let settings = huddle.agent_voice_settings.get(agent_pubkey).cloned(); + (changed, settings) + }; + if changed { + state.emit_huddle_state_changed(); + } + let Some(settings) = settings else { + return Err("Agent is not in the active huddle".to_owned()); + }; + if !settings.enabled { + return Ok(None); + } + pocket_voice_reference(app, &[settings.voice_key]).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::huddle::tts_settings::bundled_voice_registry; + + #[test] + fn first_agent_uses_default_and_additional_agents_are_distinct() { + let agents = vec!["first".to_owned(), "second".to_owned(), "third".to_owned()]; + let mut huddle = HuddleState { + huddle_generation: 9, + ..HuddleState::default() + }; + + assert!(sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:vera", + &bundled_voice_registry(), + )); + + assert_eq!( + huddle.agent_voice_settings["first"].voice_key, + "pocket:vera" + ); + let distinct: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.as_str()) + .collect(); + assert_eq!(distinct.len(), 3); + } + + #[test] + fn explicit_session_choices_survive_roster_resync() { + let agents = vec!["first".to_owned(), "second".to_owned()]; + let voices = bundled_voice_registry(); + let mut huddle = HuddleState::default(); + sync_agent_voice_assignments(&mut huddle, &agents, "pocket:mary", &voices); + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .enabled = false; + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .voice_key = "pocket:jane".into(); + + assert!(!sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:mary", + &voices, + )); + assert_eq!( + huddle.agent_voice_settings["second"], + AgentVoiceSettings { + enabled: false, + voice_key: "pocket:jane".into(), + } + ); + } +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2de22f99d8..41a348d888 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -9,14 +9,24 @@ //! when it receives the kind:9000 membership notification. Huddle-specific //! env vars (interrupt mode, custom system prompt) are a post-MVP enhancement. +use std::collections::HashSet; + use serde::Serialize; +use tauri::State; use uuid::Uuid; use crate::{ - app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + app_state::AppState, + events, + huddle::relay_api::{ + fetch_channel_members, fetch_channel_members_with_roles, validate_pubkey_hex, + MAX_HUDDLE_AGENTS, + }, relay::submit_event, }; +use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; + // ── Constants ───────────────────────────────────────────────────────────────── /// Voice-mode guidelines posted as kind:48106 (huddle guidelines) to the @@ -78,6 +88,21 @@ pub struct AgentAddResult { pub parent_error: Option, } +/// Result of reconciling channel agent additions into the active Huddle. +#[derive(Debug, Serialize)] +pub struct AgentHuddleSyncResult { + /// Whether `channel_id` belonged to the active Huddle. + pub matched_active_huddle: bool, + /// Agents newly enrolled in the Huddle's ephemeral channel. + pub added: Vec, +} + +// Multiple frontend mutation paths can observe the same membership addition +// (for example, the member hook and the mention send flow). Serialize native +// reconciliation so they share the first result instead of racing duplicate +// membership events through a relay read that has not caught up yet. +static AGENT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Add an agent to both the ephemeral and parent huddle channels. /// /// Returns `Err` only if the ephemeral-channel add fails (policy rejection or @@ -134,6 +159,156 @@ pub async fn add_agent_to_huddle( }) } +/// Reconcile explicitly added channel agents into the active Huddle. +/// +/// The source channel may be either the Huddle's parent or its ephemeral chat. +/// Existing ephemeral membership is hydrated first so a mention sent from the +/// Huddle chat does not publish a duplicate membership event. Missing agents +/// are added through the same parent + ephemeral path as the Add agent picker. +pub(crate) async fn sync_agents_for_active_huddle( + channel_id: &str, + agent_pubkeys: Vec, + state: &AppState, +) -> Result { + let mut seen = HashSet::new(); + let mut requested = Vec::new(); + for pubkey in agent_pubkeys { + let normalized = pubkey.to_ascii_lowercase(); + validate_pubkey_hex(&normalized)?; + if seen.insert(normalized.clone()) { + requested.push(normalized); + } + } + if requested.is_empty() { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let _sync_guard = AGENT_SYNC_LOCK.lock().await; + + let (ephemeral_channel_id, parent_channel_id, huddle_generation, state_agents) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let ephemeral_channel_id = huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?; + let parent_channel_id = huddle + .parent_channel_id + .clone() + .ok_or("no parent channel")?; + if channel_id != ephemeral_channel_id && channel_id != parent_channel_id { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let state_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + ( + ephemeral_channel_id, + parent_channel_id, + huddle.huddle_generation, + state_agents, + ) + }; + + // Membership reads can lag a just-accepted write, so merge the relay view + // with local state instead of allowing a stale snapshot to remove agents. + let fresh_agents = fetch_channel_members(&ephemeral_channel_id, Some("bot"), state) + .await + .unwrap_or_default(); + let mut known_agents = HashSet::new(); + let mut merged_agents = Vec::new(); + for pubkey in state_agents.into_iter().chain(fresh_agents) { + let normalized = pubkey.to_ascii_lowercase(); + if known_agents.insert(normalized.clone()) { + merged_agents.push(normalized); + } + } + let missing: Vec = requested + .into_iter() + .filter(|pubkey| !known_agents.contains(pubkey)) + .collect(); + if known_agents.len() + missing.len() > MAX_HUDDLE_AGENTS { + return Err(format!( + "agent limit reached: {} requested with {} already present (max {})", + missing.len(), + known_agents.len(), + MAX_HUDDLE_AGENTS + )); + } + + let ephemeral_uuid = Uuid::parse_str(&ephemeral_channel_id).map_err(|e| e.to_string())?; + let parent_uuid = Uuid::parse_str(&parent_channel_id).map_err(|e| e.to_string())?; + let mut added = Vec::new(); + for pubkey in missing { + add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await?; + merged_agents.push(pubkey.clone()); + added.push(pubkey); + } + + let (roster_changed, transcription_auto_enabled) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }); + } + let mut roster_changed = false; + { + let mut current_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *current_agents != merged_agents { + *current_agents = merged_agents.clone(); + roster_changed = true; + } + } + for pubkey in &merged_agents { + if !huddle.participants.contains(pubkey) { + huddle.participants.push(pubkey.clone()); + roster_changed = true; + } + } + ( + roster_changed, + huddle.maybe_auto_enable_transcription_for_agents(), + ) + }; + + if transcription_auto_enabled { + start_auto_enabled_transcription(state, &ephemeral_channel_id).await; + } else if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }) +} + +#[tauri::command] +pub async fn sync_agents_to_active_huddle( + channel_id: String, + agent_pubkeys: Vec, + state: State<'_, AppState>, +) -> Result { + sync_agents_for_active_huddle(&channel_id, agent_pubkeys, &state).await +} + fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { members .iter() diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 03264f80f4..99337400c9 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -24,6 +24,7 @@ //! and drops them outside the lock (thread joins can block ~200ms). mod agent_tts_routing; +pub mod agent_voice; pub mod agents; pub mod audio_output; pub mod jitter; @@ -41,6 +42,7 @@ pub mod tts; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; +mod window; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -68,6 +70,7 @@ pub(super) fn drain_until_shutdown( pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; +pub use window::{close_huddle_companion, open_huddle_window}; // ── Imports ─────────────────────────────────────────────────────────────────── @@ -90,6 +93,7 @@ use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, }; +use window::close_huddle_window; fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> String { let normalized = candidate @@ -175,6 +179,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -198,6 +203,15 @@ pub async fn start_huddle( deduped }; + // Allocate the backing channel ID before the relay work starts. Publishing + // it with the Creating state lets the main webview open an immediate + // companion window while the channel and audio session are being prepared. + let ephemeral_uuid = Uuid::new_v4(); + let ephemeral_channel_id = ephemeral_uuid.to_string(); + let short_id = &ephemeral_channel_id[..8]; + let fallback_channel_name = format!("huddle-{short_id}"); + let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + // Transition to Creating. let huddle_generation = { let mut hs = state.huddle()?; @@ -210,20 +224,16 @@ pub async fn start_huddle( let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); generation }; - - let ephemeral_uuid = Uuid::new_v4(); - let ephemeral_channel_id = ephemeral_uuid.to_string(); - let short_id = &ephemeral_channel_id[..8]; - let fallback_channel_name = format!("huddle-{short_id}"); - let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + state.emit_huddle_state_changed(); // All steps wrapped so we can roll back on ANY failure, including step 1. // channel_was_created tracks whether we need to archive on rollback. let mut channel_was_created = false; - let result: Result, String> = async { + let result: Result<(Vec, String), String> = async { // 1. Create ephemeral channel. let create_builder = events::build_create_channel( ephemeral_uuid, @@ -265,14 +275,14 @@ pub async fn start_huddle( // 4. Emit HUDDLE_STARTED to parent channel. let started_builder = events::build_huddle_started(&parent_channel_id, &ephemeral_channel_id)?; - submit_event(started_builder, &state).await?; + let started_event = submit_event(started_builder, &state).await?; - Ok(successful_agents) + Ok((successful_agents, started_event.event_id)) } .await; match result { - Ok(successful_agents) => { + Ok((successful_agents, huddle_thread_event_id)) => { // 5. Store active state. let committed = { let mut hs = state.huddle()?; @@ -282,6 +292,7 @@ pub async fn start_huddle( hs.phase = HuddlePhase::Connected; hs.is_creator = true; hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = Some(huddle_thread_event_id); *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = successful_agents.clone(); hs.maybe_auto_enable_transcription_for_agents(); @@ -300,6 +311,7 @@ pub async fn start_huddle( }; if !committed { emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } @@ -311,6 +323,7 @@ pub async fn start_huddle( match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { Ok(PostConnectOutcome::Ready) => {} Ok(PostConnectOutcome::Stale) => { + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } Err(e) => { @@ -330,6 +343,7 @@ pub async fn start_huddle( } state.emit_huddle_state_changed(); } + close_huddle_window(&app, &ephemeral_channel_id); return Err(e); } } @@ -350,10 +364,19 @@ pub async fn start_huddle( } } // Reset only if this failed attempt still owns the Creating state. - if let Ok(mut hs) = state.huddle_state.lock() { + let reset = if let Ok(mut hs) = state.huddle_state.lock() { if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { hs.reset_preserving_generation(); + true + } else { + false } + } else { + false + }; + if reset { + state.emit_huddle_state_changed(); + close_huddle_window(&app, &ephemeral_channel_id); } Err(e) } @@ -372,6 +395,7 @@ pub async fn start_huddle( pub async fn join_huddle( parent_channel_id: String, ephemeral_channel_id: String, + huddle_thread_event_id: Option, state: State<'_, AppState>, ) -> Result { // Transition to Connecting. @@ -387,6 +411,7 @@ pub async fn join_huddle( hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = huddle_thread_event_id; generation }; @@ -557,7 +582,7 @@ async fn remove_huddle_agents(ephemeral_channel_id: &str, state: &AppState) { /// /// The relay emits kind:48102 (participant left) when the audio WS disconnects. #[tauri::command] -pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { +pub async fn leave_huddle(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -606,6 +631,7 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { } teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -618,7 +644,11 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { /// 3. Shut down the STT pipeline (Fix 5). /// 4. Clear local huddle state. #[tauri::command] -pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Result<(), String> { +pub async fn end_huddle( + force: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -641,6 +671,7 @@ pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Resu emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -767,6 +798,8 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result, ) -> Result<(), String> { eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); @@ -774,6 +807,22 @@ pub async fn speak_agent_message( // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. let text = normalize_agent_tts_text(text); + if !state.huddle()?.tts_enabled { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + + let Some(voice_reference) = + agent_voice::voice_reference_for_agent(&app, &state, &speaker_pubkey)? + else { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=agent_disabled route_id={route_id}" + ); + return Ok(()); + }; + let needs_pipeline = { let mut hs = state.huddle()?; if hs @@ -831,7 +880,7 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, text) + .send(route_id, speaker_pubkey, voice_reference, text) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index fba5464a69..9572ac25bf 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -82,7 +82,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .map(|m| m.take_tts_ready()) .unwrap_or(false); - // Start TTS first (so STT can capture tts_cancel). + // Start TTS first so STT can observe its active-playback gate. if !has_tts && (tts_ready || models::is_tts_ready()) { if let Err(e) = maybe_start_tts_pipeline(&state).await { eprintln!("buzz-desktop: TTS hotstart failed: {e}"); @@ -130,25 +130,45 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .await .ok(); let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if !hs.is_current_huddle(eph_id, huddle_generation) { - return Ok(()); - } - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - hs.maybe_auto_enable_transcription_for_agents() - } else { - false - }; + let (roster_changed, transcription_auto_enabled) = + if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + let mut roster_changed = false; + if let Some(agents) = fresh_agents { + let mut current_agents = + hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } + } + if let Some(members) = fresh_members { + if hs.participants != members { + hs.participants = members; + roster_changed = true; + } + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) + } else { + (false, false) + }; if transcription_auto_enabled { start_auto_enabled_transcription(&state, eph_id).await; } + // Audio authentication auto-adds a joining human to the ephemeral + // channel. Emit whenever that authoritative roster changes so the + // desktop participant strip updates immediately instead of waiting + // for its slow fallback IPC read. + if roster_changed || transcription_auto_enabled { + state.emit_huddle_state_changed(); + } } } @@ -173,23 +193,32 @@ pub(crate) async fn post_connect_setup( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - let transcription_auto_enabled = { + let (roster_changed, transcription_auto_enabled) = { let mut hs = state.huddle()?; if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { return Ok(PostConnectOutcome::Stale); } + let mut roster_changed = false; if let Ok(agents) = agents_result { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + let mut current_agents = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } } if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { + if !all_members.is_empty() && hs.participants != all_members { hs.participants = all_members; + roster_changed = true; } } - hs.maybe_auto_enable_transcription_for_agents() + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) }; - if transcription_auto_enabled { + if roster_changed || transcription_auto_enabled { state.emit_huddle_state_changed(); } @@ -281,7 +310,6 @@ pub(crate) async fn maybe_start_stt_pipeline( // the worker thread (~200ms) and must not block under the mutex. let ( tts_active, - tts_cancel, agent_pubkeys_arc, session_gen, expected_generation, @@ -312,7 +340,6 @@ pub(crate) async fn maybe_start_stt_pipeline( }; ( Arc::clone(&hs.tts_active), - Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), hs.session_generation.load(Ordering::Acquire), @@ -325,7 +352,7 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) + stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) }) .await; let (pipeline, text_rx) = match constructed { @@ -421,8 +448,8 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?, + let initial_voice = match app.as_ref() { + Some(app) => super::tts_settings::pocket_voice_reference(app, &voice_preferences)?, None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences), }; @@ -458,6 +485,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result f32 { + ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) +} + +fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { + if currently_recovering { + depth > PLAYOUT_QUEUE_RECOVERY_END + } else { + depth >= PLAYOUT_QUEUE_RECOVERY_START + } +} /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// @@ -87,6 +105,7 @@ struct PeerSlot { /// by the playout tick to decide whether to keep draining NetEq into the /// Player. Updated on every successful `insert_packet`. last_packet_at: tokio::time::Instant, + recovering_playout: bool, } impl PeerSlot { @@ -96,6 +115,7 @@ impl PeerSlot { jitter, player: rodio::Player::connect_new(sink_mixer), last_packet_at: tokio::time::Instant::now(), + recovering_playout: false, }), Err(e) => { eprintln!("buzz-desktop: jitter buffer init peer {peer_idx}: {e}"); @@ -121,6 +141,19 @@ impl PeerSlot { fn is_active(&self) -> bool { self.last_packet_at.elapsed() < IDLE_PEER_GRACE || !self.jitter.is_empty() } + + fn update_playout_recovery(&mut self) { + let should_recover = should_recover_playout(self.player.len(), self.recovering_playout); + if should_recover == self.recovering_playout { + return; + } + self.recovering_playout = should_recover; + self.player.set_speed(if should_recover { + PLAYOUT_RECOVERY_SPEED + } else { + 1.0 + }); + } } /// Drive the receive loop until cancelled or the WS closes. @@ -149,12 +182,16 @@ pub(crate) async fn run_playout_recv_loop( let mut index_to_pubkey: std::collections::HashMap = initial_peers.into_iter().collect(); let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut speaker_level_tick = + tokio::time::interval(std::time::Duration::from_millis(SPEAKER_LEVEL_TICK_MS)); + speaker_level_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); // `Delay` (not `Skip`) so a brief stall in another select arm — e.g. the // ws_tx_for_pongs mutex contending with the encode-side task on a Ping — @@ -187,15 +224,14 @@ pub(crate) async fn run_playout_recv_loop( } match slot.jitter.get_audio() { Ok((samples, _vad)) => { - // Bound producer-vs-device-clock drift. If our - // tokio tick has gotten ahead of the audio - // callback's actual consumption rate, drop the - // oldest queued frame rather than letting the - // queue grow without bound. - if slot.player.len() >= PLAYOUT_QUEUE_HIGH_WATER { + // Smooth out producer-vs-device clock drift. A + // shallow hard drop used to remove entire 10 ms + // chunks and create audible discontinuities. + slot.update_playout_recovery(); + if slot.player.len() >= PLAYOUT_QUEUE_EMERGENCY_HIGH_WATER { eprintln!( - "buzz-desktop: playout queue high-water for peer {peer_idx} \ - (depth={}) — dropping oldest frame", + "buzz-desktop: playout queue emergency high-water for peer \ + {peer_idx} (depth={}) — dropping oldest frame", slot.player.len(), ); slot.player.skip_one(); @@ -221,6 +257,22 @@ pub(crate) async fn run_playout_recv_loop( } active_indices.clear(); } + _ = speaker_level_tick.tick() => { + if let Some(ref app) = app_handle { + use tauri::Emitter; + let levels: std::collections::HashMap = speaker_levels + .iter() + .filter_map(|(idx, level)| { + index_to_pubkey.get(idx).cloned().map(|pubkey| (pubkey, *level)) + }) + .collect(); + let _ = app.emit("huddle-speaker-levels", &levels); + } + for level in speaker_levels.values_mut() { + *level *= 0.55; + } + speaker_levels.retain(|_, level| *level > 0.015); + } msg = ws_rx.next() => { match msg { Some(Ok(WsMsg::Binary(data))) => { @@ -253,6 +305,11 @@ pub(crate) async fn run_playout_recv_loop( // make their tile flash for the 500 ms speaker tick. if !is_dtx { active_indices.insert(peer_idx); + let level = normalized_speaker_level(header.level_dbov); + speaker_levels + .entry(peer_idx) + .and_modify(|current| *current = current.max(level)) + .or_insert(level); } // TTS interrupt frame counter — reset on TTS rising edge. @@ -328,6 +385,7 @@ pub(crate) async fn run_playout_recv_loop( peers.remove(&key); frame_counts.remove(&key); active_indices.remove(&key); + speaker_levels.remove(&key); } index_to_pubkey.insert(key, pk.to_string()); } @@ -351,6 +409,7 @@ pub(crate) async fn run_playout_recv_loop( peers.retain(|idx, _| identity_unchanged(idx)); frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); + speaker_levels.retain(|idx, _| identity_unchanged(idx)); index_to_pubkey = replacement; } } @@ -359,6 +418,8 @@ pub(crate) async fn run_playout_recv_loop( let key = idx as u8; index_to_pubkey.remove(&key); frame_counts.remove(&key); + active_indices.remove(&key); + speaker_levels.remove(&key); // Dropping Player detaches its queue from the // device mixer, freeing the per-peer slot. peers.remove(&key); @@ -379,4 +440,34 @@ pub(crate) async fn run_playout_recv_loop( } } } + + if let Some(ref app) = app_handle { + use tauri::Emitter; + let _ = app.emit( + "huddle-speaker-levels", + &std::collections::HashMap::::new(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_level_maps_conversational_range() { + assert_eq!(normalized_speaker_level(-127), 0.0); + assert_eq!(normalized_speaker_level(-60), 0.0); + assert!((normalized_speaker_level(-36) - 0.5).abs() < f32::EPSILON); + assert_eq!(normalized_speaker_level(-12), 1.0); + assert_eq!(normalized_speaker_level(0), 1.0); + } + + #[test] + fn playout_recovery_uses_hysteresis() { + assert!(!should_recover_playout(9, false)); + assert!(should_recover_playout(10, false)); + assert!(should_recover_playout(5, true)); + assert!(!should_recover_playout(4, true)); + } } diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 37eb3533f6..0fe3a46f5a 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -4,11 +4,13 @@ //! phase enum, voice input mode, and response types. use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; +use super::agent_voice::AgentVoiceSettings; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -18,8 +20,9 @@ use super::{stt, tts}; /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// /// VAD (default): the earshot VAD runs continuously and speech is accumulated -/// whenever the probability exceeds the threshold. Barge-in is enabled in this -/// mode. +/// whenever the probability exceeds the threshold. While local TTS is playing, +/// mic frames are discarded because VAD has no echo reference with which to +/// distinguish the app's own playback from a human interruption. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { @@ -44,6 +47,9 @@ pub struct HuddleState { pub phase: HuddlePhase, pub parent_channel_id: Option, pub ephemeral_channel_id: Option, + /// Root event for the huddle's visible parent-channel thread. Transcript + /// messages reply here while audio coordination stays ephemeral. + pub huddle_thread_event_id: Option, /// Cancellation token for the audio relay WS task. #[serde(skip)] pub audio_ws_cancel: Option, @@ -67,6 +73,8 @@ pub struct HuddleState { deserialize_with = "deserialize_agent_pubkeys" )] pub agent_pubkeys: Arc>>, + /// Local, huddle-scoped playback choices for each participating agent. + pub agent_voice_settings: BTreeMap, /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, @@ -161,10 +169,12 @@ impl Clone for HuddleState { phase: self.phase.clone(), parent_channel_id: self.parent_channel_id.clone(), ephemeral_channel_id: self.ephemeral_channel_id.clone(), + huddle_thread_event_id: self.huddle_thread_event_id.clone(), audio_ws_cancel: None, // Never clone handles. audio_relay_pcm_tx: None, // Never clone handles. participants: self.participants.clone(), agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), + agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. tts_pipeline: None, // Never clone the pipeline handle. is_creator: self.is_creator, @@ -190,10 +200,12 @@ impl Default for HuddleState { phase: HuddlePhase::Idle, parent_channel_id: None, ephemeral_channel_id: None, + huddle_thread_event_id: None, audio_ws_cancel: None, audio_relay_pcm_tx: None, participants: Vec::new(), agent_pubkeys: Arc::new(Mutex::new(Vec::new())), + agent_voice_settings: BTreeMap::new(), stt_pipeline: None, tts_pipeline: None, is_creator: false, diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 6f502ca72c..30a47f449a 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -63,13 +63,13 @@ impl SttPipeline { /// /// `tts_active` is a shared flag set by the TTS pipeline while audio is /// playing. The STT worker uses it to: - /// - discard accumulated speech (echo prevention / barge-in gating) - /// - apply a 200 ms cooldown after TTS stops before re-enabling STT - /// - detect barge-in: speech onset during TTS → set `tts_cancel` + /// - discard accumulated speech so local playback cannot feed back into STT + /// - apply a cooldown after TTS stops before re-enabling STT /// - /// `tts_cancel` (optional) is the TTS pipeline's cancel flag. When the STT - /// worker detects speech onset while TTS is active, it sets this flag to - /// stop playback immediately (barge-in). Pass `None` if TTS is unavailable. + /// Open-mic VAD cannot distinguish a nearby human from the app's own native + /// TTS playback because it has no acoustic echo reference. Local mic frames + /// therefore never cancel TTS. Push-to-talk and remote participant speech + /// remain explicit, reliable barge-in paths. /// /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT /// pipeline only accumulates speech while the flag is true (key held). @@ -86,7 +86,6 @@ impl SttPipeline { pub fn new( model_dir: PathBuf, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); @@ -94,7 +93,6 @@ impl SttPipeline { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_worker = Arc::clone(&shutdown); - let tts_cancel_worker = tts_cancel.as_ref().map(Arc::clone); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) @@ -105,7 +103,6 @@ impl SttPipeline { text_tx, shutdown_worker, tts_active, - tts_cancel_worker, ptt_active_worker, ) }) @@ -167,28 +164,26 @@ impl Drop for SttPipeline { /// Previous value (28 frames / 450 ms) felt sluggish in conversation. const SILENCE_FLUSH_FRAMES: usize = 19; -/// Consecutive VAD speech frames required before triggering barge-in during TTS. -/// 20 frames × 256 samples / 16 kHz ≈ 320 ms — must be long enough to filter -/// speaker-to-mic feedback (TTS audio bleeding through the mic) while still -/// catching real human interruptions. 80 ms (previous: 5 frames) was too -/// aggressive — laptop speakers without headphones triggered false barge-in -/// within the first word of TTS playback. -const BARGE_IN_DEBOUNCE_FRAMES: usize = 20; - /// earshot requires exactly 256 samples per frame at 16 kHz. const VAD_FRAME_SAMPLES: usize = 256; /// VAD probability threshold — above this is considered speech. const VAD_THRESHOLD: f32 = 0.5; +/// Minimum voiced audio needed before an utterance may be decoded. +/// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents +/// silence/room-noise blips from reaching Parakeet and becoming hallucinated +/// transcript text while still preserving short replies such as "yes". +const MIN_VOICED_FRAMES: usize = 12; + /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); -/// 50 ms cooldown after TTS stops before STT re-enables. +/// 150 ms cooldown after TTS stops before STT re-enables. /// Prevents the tail of TTS audio from being transcribed as speech. -/// Previous value (200 ms) was eating the first word when the user spoke -/// immediately after the agent finished. -const TTS_COOLDOWN: Duration = Duration::from_millis(50); +/// This remains shorter than the previous 200 ms gate that ate the first word, +/// but is long enough for speaker/AEC tail audio to leave the microphone path. +const TTS_COOLDOWN: Duration = Duration::from_millis(150); /// Number of ONNX Runtime intra-op threads used by the offline recognizer. /// @@ -207,7 +202,6 @@ fn stt_worker( text_tx: tokio_mpsc::Sender, shutdown: Arc, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── @@ -274,9 +268,9 @@ fn stt_worker( let mut silence_frames: usize = 0; // Whether we're currently in a speech segment. let mut in_speech = false; - // Consecutive speech frames seen during TTS — used for barge-in debounce. - let mut barge_in_frames: usize = 0; - // Timestamp when TTS last stopped — used for the 200 ms cooldown. + // Number of frames earshot classified as voiced in the current segment. + let mut voiced_frames = 0; + // Timestamp when TTS last stopped — used for the playback-tail cooldown. let mut tts_stopped_at: Option = None; // ── 5. Main loop ────────────────────────────────────────────────────────── @@ -305,10 +299,11 @@ fn stt_worker( if let Some(ref ptt) = ptt_active { let ptt_now = ptt.load(Ordering::Acquire); if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { - flush_to_stt(&speech_buf, &recognizer, &text_tx); + flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; + voiced_frames = 0; } ptt_was_active = ptt_now; } @@ -342,11 +337,10 @@ fn stt_worker( &mut speech_buf, &mut silence_frames, &mut in_speech, - &mut barge_in_frames, + &mut voiced_frames, &recognizer, &text_tx, &tts_active, - tts_cancel.as_deref(), &mut tts_stopped_at, ptt_active.as_ref(), ); @@ -387,8 +381,8 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, silence_frames: &mut usize, in_speech: &mut bool, - barge_in_frames: &mut usize, + voiced_frames: &mut usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, tts_active: &Arc, - tts_cancel: Option<&AtomicBool>, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, ) { @@ -433,38 +426,16 @@ fn process_16k_samples( let tts_playing = tts_active.load(Ordering::Acquire); - // While TTS is playing: skip accumulation (echo prevention). + // While TTS is playing, discard local mic input. The native TTS output + // is not available as an echo-cancellation reference to this worker, so + // VAD cannot reliably tell speaker feedback from a human interruption. + // Push-to-talk and remote participant audio provide the intentional + // cancellation paths instead. if tts_playing { - if ptt_active.is_some() { - // PTT mode — PTT press handles TTS cancellation directly - // (via the global shortcut handler). Just skip accumulation. - *in_speech = false; - *barge_in_frames = 0; - speech_buf.clear(); - *silence_frames = 0; - continue; - } - - // VAD mode — barge-in detection. - // Without acoustic echo cancellation, this requires a longer - // debounce (BARGE_IN_DEBOUNCE_FRAMES ≈ 320 ms) to filter - // speaker-to-mic feedback. - if is_speech { - *barge_in_frames += 1; - if *barge_in_frames >= BARGE_IN_DEBOUNCE_FRAMES { - // Real speech detected during TTS — trigger barge-in. - if let Some(cancel) = tts_cancel { - cancel.store(true, Ordering::Release); - } - *barge_in_frames = 0; - } - } else { - *barge_in_frames = 0; - } - // Don't accumulate speech during TTS (echo prevention). *in_speech = false; speech_buf.clear(); *silence_frames = 0; + *voiced_frames = 0; continue; } @@ -477,28 +448,30 @@ fn process_16k_samples( } speech_buf.clear(); *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; continue; } else { // Cooldown expired — clear the timer and reset all segment state. *tts_stopped_at = None; *in_speech = false; *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; } } if is_speech { *silence_frames = 0; *in_speech = true; + *voiced_frames += 1; speech_buf.extend_from_slice(&frame); // OOM guard: flush and reset if the buffer exceeds 30 s of audio. if speech_buf.len() >= MAX_SPEECH_SAMPLES { - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } else if *in_speech { // Still accumulate during brief silence gaps. @@ -511,10 +484,11 @@ fn process_16k_samples( // threshold so each natural pause becomes a separate message. if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } // If not in speech and not accumulating, just discard the frame. @@ -527,10 +501,11 @@ fn process_16k_samples( /// The tokio channel's `blocking_send` is safe to call from sync contexts. fn flush_to_stt( speech_buf: &[f32], + voiced_frames: usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, ) { - if speech_buf.is_empty() { + if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { return; } @@ -550,6 +525,10 @@ fn flush_to_stt( } } +fn has_enough_voiced_audio(voiced_frames: usize) -> bool { + voiced_frames >= MIN_VOICED_FRAMES +} + /// Convert raw bytes (f32 LE) to f32 samples. /// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated. /// @@ -565,3 +544,15 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { // drain_until_shutdown lives in super (huddle/mod.rs) — shared with tts.rs. use super::drain_until_shutdown; + +#[cfg(test)] +mod tests { + use super::{has_enough_voiced_audio, MIN_VOICED_FRAMES}; + + #[test] + fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index c03589f9fe..1901bb3d2e 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,7 +35,7 @@ //! can gate microphone input while the agent is speaking. use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, num::NonZero, path::PathBuf, sync::{ @@ -44,7 +44,7 @@ use std::{ Arc, Mutex, MutexGuard, PoisonError, }, thread, - time::Duration, + time::{Duration, Instant}, }; use super::pocket::{ @@ -61,6 +61,9 @@ use startup::await_worker_startup; #[path = "tts_audio.rs"] mod audio; use audio::*; +#[path = "tts_activity.rs"] +mod activity; +use activity::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -77,6 +80,7 @@ const RECV_TIMEOUT: Duration = Duration::from_millis(100); /// ~5 ms — so playing audio dies ~15 ms after the flag is set, even while /// the worker is blocked inside `synth_chunk`. const MONITOR_TICK: Duration = Duration::from_millis(10); +const SPEAKER_ACTIVITY_TICK: Duration = Duration::from_millis(50); const AUDIO_PRIME_TIMEOUT: Duration = Duration::from_secs(2); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. @@ -167,10 +171,12 @@ impl TtsPipeline { cancel: Arc, voice: &str, output_device: Option, + activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); - // cancel is passed in from HuddleState.tts_cancel — shared with STT for barge-in. + // cancel is passed in from HuddleState.tts_cancel — shared with remote + // participant interruption and the push-to-talk shortcut. let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); @@ -203,6 +209,7 @@ impl TtsPipeline { (cancel_worker, worker_voice_cancel), ), output_device, + activity_app, startup_tx, ) }) @@ -231,6 +238,8 @@ impl TtsPipeline { .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), route_id: 0, + speaker_pubkey: None, + voice_reference: None, text, }) .map_err(|e| { @@ -309,6 +318,7 @@ fn tts_worker( text_rx: mpsc::Receiver, control_state: WorkerControlState, output_device: Option, + activity_app: Option, startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; @@ -351,6 +361,7 @@ fn tts_worker( )); return; } + let mut style_cache = HashMap::from([(voice_name.clone(), style.clone())]); // ── 2b. Warmup inference ───────────────────────────────────────────────── // The first ONNX inference on any session is significantly slower than @@ -454,6 +465,7 @@ fn tts_worker( // `cancel == false` and no-ops. The lock is uncontended except during an // actual barge-in, so the hot path is unaffected. let player_ops = Arc::new(Mutex::new(())); + let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); let monitor = { let player = Arc::clone(&player); @@ -462,9 +474,12 @@ fn tts_worker( let tts_active = Arc::clone(&tts_active); let stop = Arc::clone(&monitor_stop); let player_ops = Arc::clone(&player_ops); + let activity_frames = Arc::clone(&activity_frames); thread::Builder::new() .name("tts-barge-in-monitor".into()) .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); while !stop.load(Ordering::Acquire) { if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { let _ops = lock_player_ops(&player_ops); @@ -481,6 +496,46 @@ fn tts_worker( tts_active.store(false, Ordering::Release); } } + if let Some(ref app) = activity_app { + if tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } thread::sleep(MONITOR_TICK); } }) @@ -507,7 +562,9 @@ fn tts_worker( let mut first_append = true; let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); - let append_audio = |prepared: PreparedModelAudio, route_id: u64| { + let append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -525,6 +582,16 @@ fn tts_worker( ); return false; } + if let Some(pubkey) = speaker_pubkey { + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(build_tts_speaker_activity_frames( + &prepared.buffer, + pubkey, + SAMPLE_RATE as usize, + )); + } player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); eprintln!( "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", @@ -555,14 +622,19 @@ fn tts_worker( continue; } - // Voice changes cancel the old utterance/queue and are observed here, - // before receiving subsequent text. A bad bundled asset falls back to - // Mary without discarding the already-warmed Pocket engine. - let voice_ready = - reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); - acknowledge_voice_change(&voice_change_ack, &voice_cancel); - if !voice_ready { - continue; + // A global Settings voice change cancels the old utterance and is + // acknowledged before receiving subsequent text. Per-agent voice + // changes are carried by each queue item and never drain other agents. + if has_pending_voice_change(&voice_change_ack) { + let voice_ready = + reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); + if voice_ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + if !voice_ready { + continue; + } } let mut queued_text = Some(match deferred_text.pop_front() { @@ -614,15 +686,28 @@ fn tts_worker( ); continue; } + let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + }); let raw_text = queued_text.text; + let speaker_pubkey = queued_text.speaker_pubkey; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); - // The selected voice can change while this worker is blocked in - // recv_timeout. Reconcile again after receipt so the first message - // queued after an unpublished pipeline is installed cannot use the - // voice captured when construction began. - if !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) { + // The selected per-agent voice travels with the queue item, preserving + // message order while allowing one warmed Pocket engine to alternate + // between cached reference styles. + if !reconcile_queued_voice( + &model_dir, + &requested_voice, + &selected_voice, + &mut voice_name, + &mut style, + &mut style_cache, + ) { eprintln!( "buzz-desktop: tts stage=synthesis status=failed reason=voice_unavailable route_id={route_id}" ); @@ -761,7 +846,7 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -787,7 +872,7 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_activity.rs b/desktop/src-tauri/src/huddle/tts_activity.rs new file mode 100644 index 0000000000..8e69609186 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_activity.rs @@ -0,0 +1,45 @@ +//! Agent TTS activity envelope shared with the participant film strip. + +#[derive(Clone, serde::Serialize)] +pub(super) struct TtsSpeakerActivityPayload { + pub(super) pubkey: Option, + pub(super) level: f32, +} + +pub(super) struct TtsSpeakerActivityFrame { + pub(super) pubkey: String, + pub(super) level: f32, +} + +/// Build a 50 ms RMS envelope from the exact audio queued for playback. +/// The UI consumes these frames at the same cadence as remote speaker levels, +/// so an agent uses the normal participant ring rather than a generic pulse. +pub(super) fn build_tts_speaker_activity_frames( + samples: &[f32], + pubkey: &str, + sample_rate: usize, +) -> Vec { + let samples_per_frame = (sample_rate / 20).max(1); + samples + .chunks(samples_per_frame) + .map(|frame| { + let mean_square = frame + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::() + / frame.len().max(1) as f64; + let rms = mean_square.sqrt() as f32; + let level = if rms <= 0.000_5 { + 0.0 + } else { + // Map roughly -60 dB..-12 dB into the same normalized range + // used by remote Opus speaker levels. + ((20.0 * rms.log10() + 60.0) / 48.0).clamp(0.12, 1.0) + }; + TtsSpeakerActivityFrame { + pubkey: pubkey.to_string(), + level, + } + }) + .collect() +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 1b378af823..64fd6d8a94 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -175,7 +175,7 @@ pub fn resolve_voice_for_backend( resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry()) } -fn resolve_voice_for_backend_in_registry( +pub(crate) fn resolve_voice_for_backend_in_registry( preferences: &[String], backend: &str, registry: &[VoiceRegistryEntry], @@ -624,6 +624,7 @@ pub async fn preview_pocket_voice( cancel, &voice_name, output_device, + None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1908b096b1..1dee4de90c 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -32,6 +32,19 @@ mod token_split; // - Counters reset on the 500ms window (Instant-based in production, // on_tick() in tests — logically equivalent). // - Uses Acquire for tts_active reads, Release for tts_cancel writes. + +#[test] +fn tts_speaker_activity_uses_the_playback_waveform() { + let mut samples = vec![0.0; 1_200]; + samples.extend(vec![0.25; 1_200]); + + let frames = build_tts_speaker_activity_frames(&samples, "agent-pubkey", 24_000); + + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].pubkey, "agent-pubkey"); + assert_eq!(frames[0].level, 0.0); + assert!(frames[1].level > 0.5); +} // use crate::huddle::relay_api::REMOTE_SPEECH_THRESHOLD; @@ -287,24 +300,6 @@ fn cancel_already_true_is_harmless() { ); } -// ── Regression: local-only interrupt still works ────────────────────────── - -/// The existing local barge-in path (STT detects speech → sets tts_cancel) -/// must continue to work independently of remote frame counting. -#[test] -fn local_barge_in_still_works_without_remote_frames() { - let _tts_active = AtomicBool::new(true); - let tts_cancel = AtomicBool::new(false); - - // Simulate local STT barge-in (stt.rs after BARGE_IN_DEBOUNCE_FRAMES). - tts_cancel.store(true, Ordering::Release); - - assert!( - tts_cancel.load(Ordering::Acquire), - "local barge-in should set tts_cancel", - ); -} - // ── Cancel consumption tests (TTS worker side) ──────────────────────────── /// TTS worker correctly resets both tts_cancel and tts_active after cancel. diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 45662c9921..044b1acf1e 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -167,6 +167,8 @@ fn an_in_hand_post_change_message_survives_cancellation() { .send(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 1, + speaker_pubkey: None, + voice_reference: None, text: "new message".to_string(), }) .expect("new message"); @@ -178,11 +180,15 @@ fn an_in_hand_post_change_message_survives_cancellation() { QueuedText { generation: 1, route_id: 2, + speaker_pubkey: None, + voice_reference: None, text: "old message".to_string(), }, QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 3, + speaker_pubkey: None, + voice_reference: None, text: "later new message".to_string(), }, ]); @@ -239,6 +245,8 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 4, + speaker_pubkey: None, + voice_reference: None, text: "message for Eve".to_string(), }); assert!(handle_cancel_or_shutdown( @@ -285,6 +293,8 @@ fn barge_in_clears_deferred_voice_change_messages() { let mut deferred_text = VecDeque::from([QueuedText { generation: 2, route_id: 5, + speaker_pubkey: None, + voice_reference: None, text: "deferred message".to_string(), }]); let mut current_text = None; @@ -326,6 +336,8 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 6, + speaker_pubkey: None, + voice_reference: None, text: "post-change message".to_string(), }); barge_in.store(true, Ordering::Release); @@ -377,9 +389,15 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() None, )); old_sender - .send(7, "late old message".to_string()) + .send( + 7, + "agent".to_string(), + "reference_sample".to_string(), + "late old message".to_string(), + ) .expect("late send"); let late = text_rx.recv().expect("late queued text"); assert!(late.generation < voice_generation.load(Ordering::Acquire)); + assert_eq!(late.voice_reference.as_deref(), Some("reference_sample")); } diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 81b33672d3..3a65553756 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,5 @@ use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -30,6 +30,8 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, + pub(super) speaker_pubkey: Option, + pub(super) voice_reference: Option, pub(super) text: String, } @@ -40,17 +42,32 @@ pub(crate) struct TtsTextSender { } impl TtsTextSender { - pub(crate) fn send(&self, route_id: u64, text: String) -> Result<(), String> { + pub(crate) fn send( + &self, + route_id: u64, + speaker_pubkey: String, + voice_reference: String, + text: String, + ) -> Result<(), String> { self.text_tx .send(QueuedText { generation: self.generation, route_id, + speaker_pubkey: Some(speaker_pubkey), + voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } } +pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { + voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() +} + pub(super) fn begin_voice_change( selected_voice: &Mutex, voice_generation: &AtomicU64, @@ -151,6 +168,43 @@ pub(super) fn reconcile_selected_voice( } } +pub(super) fn reconcile_queued_voice( + model_dir: &Path, + requested_voice: &str, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, + style_cache: &mut HashMap, +) -> bool { + if requested_voice == voice_name.as_str() { + return true; + } + if let Some(cached) = style_cache.get(requested_voice) { + *style = cached.clone(); + *voice_name = requested_voice.to_owned(); + return true; + } + + match load_voice_style(&voice_path(model_dir, requested_voice)) { + Ok(requested_style) => { + style_cache.insert(requested_voice.to_owned(), requested_style.clone()); + *style = requested_style; + *voice_name = requested_voice.to_owned(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=agent_voice_switch status=fallback reason=voice_style" + ); + let ready = reconcile_selected_voice(model_dir, selected_voice, voice_name, style); + if ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + ready + } + } +} + pub(super) fn voice_path(model_dir: &Path, voice: &str) -> std::path::PathBuf { let path = Path::new(voice); if path.is_absolute() { diff --git a/desktop/src-tauri/src/huddle/window.rs b/desktop/src-tauri/src/huddle/window.rs new file mode 100644 index 0000000000..cb3cfc8bfd --- /dev/null +++ b/desktop/src-tauri/src/huddle/window.rs @@ -0,0 +1,67 @@ +//! Native companion-window lifecycle for an active Huddle. + +use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; + +use crate::app_state::AppState; + +/// Close the companion belonging to an ended huddle. The native lifecycle is +/// authoritative here because a webview can be suspended while it is closing. +pub(super) fn close_huddle_window(app: &tauri::AppHandle, ephemeral_channel_id: &str) { + if ephemeral_channel_id.is_empty() { + return; + } + let label = format!("huddle-{ephemeral_channel_id}"); + if let Some(window) = app.get_webview_window(&label) { + if let Err(error) = window.close() { + eprintln!("buzz-desktop: failed to close huddle companion: {error}"); + } + } +} + +/// Close the active companion without leaving the huddle. The main window uses +/// this to restore its drawer presentation while retaining the audio session. +#[tauri::command] +pub fn close_huddle_companion( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + close_huddle_window(&app, &ephemeral_channel_id); + app.emit("huddle-companion-returned", ()) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Open the active huddle's ephemeral channel in a focused companion window. +/// The main window remains the owner of microphone capture; closing this room +/// must never leave the shared huddle session. +#[tauri::command] +pub async fn open_huddle_window( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + let label = format!("huddle-{ephemeral_channel_id}"); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new(&app, label, WebviewUrl::App("index.html".into())) + .title("Huddle") + .inner_size(960.0, 720.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs new file mode 100644 index 0000000000..b124551512 --- /dev/null +++ b/desktop/src-tauri/src/initial_window.rs @@ -0,0 +1,67 @@ +//! First-frame window reveal helpers. + +#[cfg(target_os = "macos")] +pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + +pub(crate) fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn wait_for_stable_initial_window_geometry( + window: &tauri::Window, +) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + for _ in 0..MAX_POLLS { + // Accept whatever geometry the window-state plugin restores — maximized + // or a normal saved size. macOS applies the restore asynchronously, so + // consecutive identical outer bounds are enough to know it settled. + let bounds = match (window.outer_position(), window.outer_size()) { + (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), + _ => None, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..7c5530db74 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod event_sync; mod events; mod huddle; mod identity_storage; +mod initial_window; mod key_backup; mod linux_media; mod managed_agents; @@ -49,11 +50,13 @@ use huddle::audio_output::{ }; use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, - end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, - join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, - set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + 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, join_huddle, leave_huddle, open_huddle_window, + push_audio_pcm, 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::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -66,80 +69,13 @@ use mesh_llm_stubs::*; use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] -use tauri::{Listener, WindowEvent}; +use tauri::Listener; +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; -#[cfg(target_os = "macos")] -const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; - -fn reveal_initial_window(window: &tauri::Window) { - if let Err(error) = window.show() { - eprintln!("buzz-desktop: failed to reveal main window: {error}"); - return; - } - if let Err(error) = window.set_focus() { - eprintln!("buzz-desktop: failed to focus main window: {error}"); - } -} - -#[cfg(target_os = "macos")] -fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. - if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { - eprintln!("buzz-desktop: failed to set initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn clear_initial_window_backing(window: &tauri::Window) { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - if let Err(error) = window.set_background_color(None) { - eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { - const MAX_POLLS: usize = 120; - const REQUIRED_STABLE_POLLS: usize = 4; - - let mut previous_bounds = None; - let mut stable_polls = 0; - - for _ in 0..MAX_POLLS { - // Accept whatever geometry the window-state plugin restores — maximized - // or a normal saved size. macOS applies the restore asynchronously, so - // we only need consecutive identical outer bounds to know it settled. - // Gating on `is_maximized()` here would leave `bounds` permanently - // `None` for restored non-maximized windows and stall the reveal until - // the poll timeout. - let bounds = match (window.outer_position(), window.outer_size()) { - (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), - _ => None, - }; - - if bounds.is_some() && bounds == previous_bounds { - stable_polls += 1; - if stable_polls >= REQUIRED_STABLE_POLLS { - return; - } - } else { - stable_polls = 0; - } - previous_bounds = bounds; - - tokio::time::sleep(std::time::Duration::from_millis(16)).await; - } - - eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -885,6 +821,8 @@ pub fn run() { leave_huddle, end_huddle, get_huddle_state, + close_huddle_companion, + open_huddle_window, push_audio_pcm, reconnect_huddle_audio, start_stt_pipeline, @@ -898,8 +836,12 @@ pub fn run() { huddle::tts_settings::preview_pocket_voice, huddle::tts_settings::import_pocket_voice, huddle::tts_settings::delete_pocket_voice, + huddle::agent_voice::ensure_huddle_agent_voice_settings, + huddle::agent_voice::set_huddle_agent_tts_enabled, + huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, add_agent_to_huddle, + huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, perform_sidebar_default_haptic, @@ -971,6 +913,29 @@ pub fn run() { } } } + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { .. }, + .. + } if label.starts_with("huddle-") => { + let is_active_huddle_window = + app_handle + .state::() + .huddle() + .ok() + .is_some_and(|huddle| { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle + .ephemeral_channel_id + .as_deref() + .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) + }); + if is_active_huddle_window { + if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { + eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); + } + } + } RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 44618f2c72..104edcbaaf 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -652,12 +653,13 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [activeCommunity, communityOnboarding.start], ); - // Deep links are captured here — above the machine-onboarding gate — not in - // CommunityApp. The Rust side queues them; draining into the persisted - // community-onboarding transaction immediately means an invite opened on a - // fresh install is acknowledged on screen while the identity steps are - // still pending, and survives a relaunch in between. + // Community links are app-global work. A Huddle companion loads the same + // React tree, but must never race the main window for the native pending-link + // queue or replace its dedicated transcript surface with onboarding. + const acceptsCommunityDeepLinks = huddleWindowChannelId() === null; useEffect(() => { + if (!acceptsCommunityDeepLinks) return; + const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, openAddCommunity, @@ -666,7 +668,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { return () => { void unlisten.then((fn) => fn()); }; - }, [communityOnboarding.start, openAddCommunity]); + }, [acceptsCommunityDeepLinks, communityOnboarding.start, openAddCommunity]); if (machine.stage === "reset-failed") return ; if (machine.stage === "keyring-locked") return ; diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx index 9fa12d513f..5dfc31d41c 100644 --- a/desktop/src/app/AppHuddleBar.tsx +++ b/desktop/src/app/AppHuddleBar.tsx @@ -6,10 +6,12 @@ import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; type AppHuddleBarProps = Pick< React.ComponentProps, - "onOpenThread" | "onVisibilityChange" + "mode" | "onOpenHuddleWindow" | "onOpenThread" | "onVisibilityChange" >; export function AppHuddleBar({ + mode, + onOpenHuddleWindow, onOpenThread, onVisibilityChange, }: AppHuddleBarProps) { @@ -17,6 +19,8 @@ export function AppHuddleBar({ diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx new file mode 100644 index 0000000000..8370e8efd4 --- /dev/null +++ b/desktop/src/app/AppHuddleShell.tsx @@ -0,0 +1,76 @@ +import type * as React from "react"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleProvider } from "@/features/huddle"; +import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { cn } from "@/shared/lib/cn"; + +type AppHuddleShellProps = { + children: React.ReactNode; + currentPubkey?: string; + isCompanionOpen: boolean; + isDrawerOpen: boolean; + isRoom: boolean; + onCompanionOpen: () => void; + onHuddleStartPendingChange: (pending: boolean) => void; + onHuddleStarted: (ephemeralChannelId: string) => void | Promise; + onShowHuddleInMainApp: (ephemeralChannelId: string) => void; + onViewHuddleChannel: (ephemeralChannelId: string) => void; + onVisibilityChange: (visible: boolean) => void; +}; + +export function AppHuddleShell({ + children, + currentPubkey, + isCompanionOpen, + isDrawerOpen, + isRoom, + onCompanionOpen, + onHuddleStartPendingChange, + onHuddleStarted, + onShowHuddleInMainApp, + onViewHuddleChannel, + onVisibilityChange, +}: AppHuddleShellProps) { + return ( + + +
+
+ + {children} +
+ {isRoom || !isCompanionOpen ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 46e06b2f9f..e5f9f6d866 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -3,8 +3,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; -import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; import { AppShellOverlays } from "@/app/AppShellOverlays"; +import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; +import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; @@ -18,6 +19,8 @@ import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; +import { useHuddlePresentation } from "@/app/useHuddlePresentation"; +import { shouldShowSidebarChannel } from "@/app/huddleChannelVisibility"; import { channelsQueryKey, useChannelsQuery, @@ -62,10 +65,7 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -import { HuddleProvider } from "@/features/huddle"; -import { AppHuddleBar } from "@/app/AppHuddleBar"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; -import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; @@ -86,28 +86,38 @@ import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; -import type { ChannelVisibility, SearchHit } from "@/shared/api/types"; +import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; -import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; -import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; +import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; -const LazySettingsScreen = React.lazy(async () => { - const module = await import("@/features/settings/ui/SettingsScreen"); - return { default: module.SettingsScreen }; -}); - +import { LazySettingsScreen } from "@/app/LazySettingsScreen"; +const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); const communitiesHook = useCommunities(); + const { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + showHuddleInMainApp, + viewHuddleChannel, + } = useHuddlePresentation(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = @@ -118,7 +128,6 @@ export function AppShell() { const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); - const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); @@ -238,8 +247,17 @@ export function AppShell() { [channels], ); const sidebarChannels = React.useMemo( - () => memberChannels.filter((channel) => channel.archivedAt === null), - [memberChannels], + () => + memberChannels.filter( + (channel) => + channel.archivedAt === null && + shouldShowSidebarChannel( + channel, + huddleBackingChannelIds, + revealedHuddleChannelIds, + ), + ), + [huddleBackingChannelIds, memberChannels, revealedHuddleChannelIds], ); const hasRestoredCommunityDestinationRef = React.useRef(false); React.useEffect(() => { @@ -308,6 +326,7 @@ export function AppShell() { handleThreadReplyDesktopNotification, } = useAppShellDesktopNotifications({ channels, + enabled: !isHuddleRoom, goChannel, goHome, notificationSettings: notificationSettings.settings, @@ -344,19 +363,23 @@ export function AppShell() { mutedRootIds, muteThread, unmuteThread, - } = useUnreadChannels(sidebarChannels, activeChannel, { - pubkey: identityQuery.data?.pubkey, - relayClient, - relayUrl: communitiesHook.activeCommunity?.relayUrl, - currentPubkey: identityQuery.data?.pubkey, - mutedChannelIds, - notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, - onChannelMessage: handleChannelNotification, - onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedFromLiveSignal, - onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, - followedRootIds, - }); + } = useUnreadChannels( + isHuddleRoom ? EMPTY_CHANNELS : sidebarChannels, + isHuddleRoom ? null : activeChannel, + { + pubkey: identityQuery.data?.pubkey, + relayClient, + relayUrl: communitiesHook.activeCommunity?.relayUrl, + currentPubkey: identityQuery.data?.pubkey, + mutedChannelIds, + notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, + onChannelMessage: handleChannelNotification, + onDmMessage: handleDmNotification, + onLiveMention: refetchHomeFeedFromLiveSignal, + onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, + followedRootIds, + }, + ); const { getThreadReadAt, @@ -397,6 +420,7 @@ export function AppShell() { markChannelRead, unreadThreadFeedItems, ]); + // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( @@ -404,6 +428,7 @@ export function AppShell() { identityQuery.data?.pubkey, notificationSettings.settings, notificationSettings.setDesktopEnabled, + !isHuddleRoom, selectedView === "home" && !settingsOpen, getChannelReadAt, readStateVersion, @@ -603,18 +628,20 @@ export function AppShell() { [openSearchHit], ); useAppShellLifecycleEffects({ + desktopBadgeEnabled: !isHuddleRoom, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links into the router. - useMessageDeepLinks(); + // Dispatch `buzz://message` deep links only from the main window. The + // companion is dedicated to its active Huddle route. + useMessageDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], ); React.useLayoutEffect(() => { - if (settingsOpen) { + if (settingsOpen || isHuddleRoom) { return; } @@ -674,12 +701,13 @@ export function AppShell() { handleOpenSearch, goNewMessage, goHome, + isHuddleRoom, settingsOpen, ]); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, - open: settingsOpen, + open: isHuddleRoom ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, @@ -690,11 +718,13 @@ export function AppShell() { }); return ( - + {!isHuddleRoom ? ( + + ) : null} - - -
-
- - {hasCommunityRail ? ( - void handleRemoveCommunity(id)} - onReorderCommunities={communitiesHook.reorderCommunities} - onSwitchCommunity={handleSwitchCommunity} - onUpdateCommunity={communitiesHook.updateCommunity} - communities={communitiesHook.communities} - /> - ) : null} - - - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? - identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={goNewMessage} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) - } - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined - } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - + {hasCommunityRail && !isHuddleRoom ? ( + void handleRemoveCommunity(id)} + onReorderCommunities={communitiesHook.reorderCommunities} + onSwitchCommunity={handleSwitchCommunity} + onUpdateCommunity={communitiesHook.updateCommunity} + communities={communitiesHook.communities} + /> + ) : null} + + + {!settingsOpen && !isHuddleRoom ? ( + + ) : null} + {settingsOpen ? ( +
+ + { - setIsChannelManagementOpen(open); - if (!open) { - setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); + notificationErrorMessage={ + notificationSettings.errorMessage + } + notificationPermission={notificationSettings.permission} + notificationSettings={notificationSettings.settings} + onClose={handleCloseSettings} + onSectionChange={handleSettingsSectionChange} + onSetDesktopNotificationsEnabled={ + notificationSettings.setDesktopEnabled + } + onSetHomeBadgeEnabled={ + notificationSettings.setHomeBadgeEnabled + } + onSetSlotAlertsEnabled={ + notificationSettings.setSlotAlertsEnabled + } + onSetNotifyWhileViewing={ + notificationSettings.setNotifyWhileViewing + } + onSetAllSlotAlertsEnabled={ + notificationSettings.setAllSlotAlertsEnabled + } + onSetSoundForSlot={notificationSettings.setSoundForSlot} + section={settingsSection} + /> + +
+ ) : ( +
+ {!isHuddleRoom ? ( + { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); }} - onSelectChannel={(channelId) => { - void goChannel(channelId); + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) + } + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onHuddleEnded={handleHuddleEnded} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); }} + onSelectAgents={() => void goAgents()} + onSelectChannel={handleSidebarChannelSelect} + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequest={searchFocusRequest} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) + } + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) + } + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} /> - + + + {!isHuddleRoom ? ( + - - -
- -
- { - void goChannel(channelId, { - messageId, - threadRootId: messageId, - }); - }} - onVisibilityChange={setIsHuddleDrawerOpen} - /> -
-
- - + ) : null} +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); + } + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); + setManagedChannelId(null); + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
+ + diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx new file mode 100644 index 0000000000..4ab2ea1df3 --- /dev/null +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -0,0 +1,43 @@ +import type * as React from "react"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle"; +import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; +import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { SidebarInset } from "@/shared/ui/sidebar"; + +type AppShellChannelSurfaceProps = { + children: React.ReactNode; + isHuddleRoom: boolean; + isHuddleRoomStarting: boolean; + mainInsetRef: React.RefObject; +}; + +export function AppShellChannelSurface({ + children, + isHuddleRoom, + isHuddleRoomStarting, + mainInsetRef, +}: AppShellChannelSurfaceProps) { + return ( + + + {isHuddleRoom && !isHuddleRoomStarting ? : null} + + {isHuddleRoomStarting ? : children} + + + + ); +} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 80461f0185..4976fc2ed8 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -7,6 +7,7 @@ export function GradientLayer() { className="buzz-theme-gradient-layer pointer-events-none absolute inset-0 -z-10" data-buzz-gradient-layer > +
{children}
diff --git a/desktop/src/app/LazySettingsScreen.tsx b/desktop/src/app/LazySettingsScreen.tsx new file mode 100644 index 0000000000..8308ec723b --- /dev/null +++ b/desktop/src/app/LazySettingsScreen.tsx @@ -0,0 +1,6 @@ +import * as React from "react"; + +export const LazySettingsScreen = React.lazy(async () => { + const module = await import("@/features/settings/ui/SettingsScreen"); + return { default: module.SettingsScreen }; +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.test.mjs b/desktop/src/app/huddleBackingChannelStorage.test.mjs new file mode 100644 index 0000000000..f88c0e1feb --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class MemoryStorage { + values = new Map(); + getItem(key) { + return this.values.get(key) ?? null; + } + setItem(key, value) { + this.values.set(key, value); + } +} + +globalThis.window = { localStorage: new MemoryStorage() }; +const { loadHuddleBackingChannelIds, rememberHuddleBackingChannelId } = + await import("./huddleBackingChannelStorage.ts"); + +test("restores remembered Huddle backing channels", () => { + rememberHuddleBackingChannelId("first"); + rememberHuddleBackingChannelId("second"); + rememberHuddleBackingChannelId("first"); + assert.deepEqual([...loadHuddleBackingChannelIds()], ["second", "first"]); +}); + +test("bounds persisted backing channels", () => { + for (let index = 0; index < 105; index += 1) { + rememberHuddleBackingChannelId(`channel-${index}`); + } + const ids = [...loadHuddleBackingChannelIds()]; + assert.equal(ids.length, 100); + assert.equal(ids.at(0), "channel-5"); + assert.equal(ids.at(-1), "channel-104"); +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.ts b/desktop/src/app/huddleBackingChannelStorage.ts new file mode 100644 index 0000000000..44b21a25d2 --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "buzz:huddle-backing-channel-ids:v1"; +const MAX_TRACKED_CHANNELS = 100; + +function readStoredIds(): string[] { + try { + const value = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]"); + return Array.isArray(value) + ? value.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +/** Restores Huddle implementation channels after an abnormal app restart. */ +export function loadHuddleBackingChannelIds(): ReadonlySet { + return new Set(readStoredIds()); +} + +/** + * Remembers a backing channel beyond the native Huddle process lifetime. + * Relay archive/removal can lag, so IDs remain hidden if the app crashes or is + * force-quit. The bounded list prevents abandoned local state growing forever. + */ +export function rememberHuddleBackingChannelId(channelId: string): void { + const ids = readStoredIds().filter((id) => id !== channelId); + ids.push(channelId); + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify(ids.slice(-MAX_TRACKED_CHANNELS)), + ); + } catch { + // Storage can be unavailable in browser previews or locked-down webviews. + } +} diff --git a/desktop/src/app/huddleChannelVisibility.test.mjs b/desktop/src/app/huddleChannelVisibility.test.mjs new file mode 100644 index 0000000000..b08306e9e7 --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isHuddleBackingChannel, + shouldShowSidebarChannel, +} from "./huddleChannelVisibility.ts"; + +function channel(overrides = {}) { + return { + id: "channel-id", + name: "general", + ttlSeconds: null, + ...overrides, + }; +} + +test("ordinary channels stay visible without an explicit reveal", () => { + assert.equal(shouldShowSidebarChannel(channel(), new Set(), new Set()), true); +}); + +test("tracked huddle backing channels stay hidden by default", () => { + const huddle = channel({ + id: "stale-huddle", + name: "general huddle", + ttlSeconds: 3_600, + }); + const huddleBackingChannelIds = new Set([huddle.id]); + + assert.equal(isHuddleBackingChannel(huddle, huddleBackingChannelIds), true); + assert.equal( + shouldShowSidebarChannel(huddle, huddleBackingChannelIds, new Set()), + false, + ); +}); + +test("an explicitly revealed huddle channel appears in the sidebar", () => { + const huddle = channel({ + id: "active-huddle", + name: "huddle", + ttlSeconds: 3_600, + }); + + assert.equal( + shouldShowSidebarChannel( + huddle, + new Set([huddle.id]), + new Set([huddle.id]), + ), + true, + ); +}); + +test("one-hour channels with huddle-shaped names remain ordinary", () => { + const ordinaryChannel = channel({ + name: "design huddle", + ttlSeconds: 3_600, + }); + + assert.equal(isHuddleBackingChannel(ordinaryChannel, new Set()), false); + assert.equal( + shouldShowSidebarChannel(ordinaryChannel, new Set(), new Set()), + true, + ); +}); diff --git a/desktop/src/app/huddleChannelVisibility.ts b/desktop/src/app/huddleChannelVisibility.ts new file mode 100644 index 0000000000..cee0717bee --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.ts @@ -0,0 +1,19 @@ +import type { Channel } from "@/shared/api/types"; + +export function isHuddleBackingChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, +): boolean { + return huddleBackingChannelIds.has(channel.id); +} + +export function shouldShowSidebarChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, + revealedHuddleChannelIds: ReadonlySet, +): boolean { + return ( + !isHuddleBackingChannel(channel, huddleBackingChannelIds) || + revealedHuddleChannelIds.has(channel.id) + ); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index d19ac03120..5afd0e7e4b 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -171,6 +171,8 @@ export function useAppNavigation() { autoSend?: string; messageId?: string; replace?: boolean; + /** Open this thread panel directly without waiting for a timeline row. */ + thread?: string; threadRootId?: string | null; }, ) => @@ -190,6 +192,7 @@ export function useAppNavigation() { ...(options?.agentSession ? { agentSession: options.agentSession } : {}), + ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, }, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 9e689ab507..d626179ebb 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -4,6 +4,8 @@ import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; +import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { getThreadReference, isBroadcastReply, @@ -102,6 +104,7 @@ export function ChannelRouteScreen({ targetReplyId, targetThreadRootId, }: ChannelRouteScreenProps) { + const isHuddleTranscript = huddleWindowChannelId() !== null; const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -186,6 +189,9 @@ export function ChannelRouteScreen({ }, [selectedPostId, targetMessageId, targetThreadRootId]); if (channelsQuery.isPending && !activeChannel) { + if (isHuddleTranscript) { + return ; + } return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const isHuddleTranscript = huddleWindowChannelId() !== null; return ( } + fallback={ + isHuddleTranscript ? ( + + ) : ( + + ) + } > Promise; goHome: () => Promise; notificationSettings: NotificationSettings; @@ -42,6 +44,7 @@ export function useAppShellDesktopNotifications({ }) { const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { + if (!enabled) return; if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; void requestDockBounce(); @@ -50,6 +53,7 @@ export function useAppShellDesktopNotifications({ const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.dm @@ -84,6 +88,7 @@ export function useAppShellDesktopNotifications({ const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.thread_reply @@ -151,6 +156,7 @@ export function useAppShellDesktopNotifications({ ); React.useEffect(() => { + if (!enabled) return; let isCancelled = false; let cleanup = () => {}; @@ -173,7 +179,7 @@ export function useAppShellDesktopNotifications({ isCancelled = true; cleanup(); }; - }, []); + }, [enabled]); return { handleChannelNotification, diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index cc0447cc32..02c97bac57 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -4,12 +4,14 @@ import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; type AppShellLifecycleEffectsOptions = { + desktopBadgeEnabled: boolean; homeBadgeCountExcludingHighPriority: number; unreadChannelIds: ReadonlySet; unreadChannelNotificationCount: number; }; export function useAppShellLifecycleEffects({ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, @@ -64,6 +66,10 @@ export function useAppShellLifecycleEffects({ }, []); React.useEffect(() => { + if (!desktopBadgeEnabled) { + return; + } + const count = unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; void setDesktopAppBadge( @@ -72,6 +78,7 @@ export function useAppShellLifecycleEffects({ : { kind: unreadChannelIds.size ? "dot" : "none" }, ); }, [ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts new file mode 100644 index 0000000000..ddc8674714 --- /dev/null +++ b/desktop/src/app/useHuddlePresentation.ts @@ -0,0 +1,444 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import * as React from "react"; +import { + loadHuddleBackingChannelIds, + rememberHuddleBackingChannelId, +} from "@/app/huddleBackingChannelStorage"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { channelsQueryKey } from "@/features/channels/hooks"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { + channelMessagesKey, + channelWindowKey, +} from "@/features/messages/lib/messageQueryKeys"; + +type HuddleTranscriptRouteState = { + phase: + | "idle" + | "creating" + | "connecting" + | "connected" + | "active" + | "leaving"; + parent_channel_id: string | null; + ephemeral_channel_id: string | null; + huddle_thread_event_id: string | null; +}; + +export function useHuddlePresentation() { + const huddleRoomChannelId = huddleWindowChannelId(); + const isHuddleRoom = huddleRoomChannelId !== null; + const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); + const [isHuddleCompanionOpen, setIsHuddleCompanionOpen] = + React.useState(false); + const [isHuddleStartPending, setIsHuddleStartPending] = React.useState(false); + const [revealedHuddleChannelIds, setRevealedHuddleChannelIds] = + React.useState>(() => new Set()); + const [huddleBackingChannelIds, setHuddleBackingChannelIds] = React.useState< + ReadonlySet + >(loadHuddleBackingChannelIds); + const activeHuddleChannelIdRef = React.useRef(null); + const huddleCompanionChannelIdRef = React.useRef(null); + const huddleCompanionDismissedChannelIdRef = React.useRef( + null, + ); + const huddleCompanionOpenPromiseRef = React.useRef | null>( + null, + ); + const activeHuddleParentChannelIdRef = React.useRef(null); + const [huddleTranscriptRoute, setHuddleTranscriptRoute] = + React.useState(null); + const location = useLocation(); + const queryClient = useQueryClient(); + const { goChannel } = useAppNavigation(); + + React.useEffect(() => { + if (!isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + const syncRoute = (state: HuddleTranscriptRouteState) => { + if (!cancelled) setHuddleTranscriptRoute(state); + }; + + void invoke("get_huddle_state") + .then(syncRoute) + .catch((error) => { + console.error("Failed to resolve huddle transcript route:", error); + if (!cancelled) { + setHuddleTranscriptRoute({ + ephemeral_channel_id: huddleRoomChannelId, + huddle_thread_event_id: null, + parent_channel_id: null, + phase: "active", + }); + } + }); + void listen("huddle-state-changed", (event) => + syncRoute(event.payload), + ).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [huddleRoomChannelId, isHuddleRoom]); + + const huddleRouteResolved = huddleTranscriptRoute !== null; + const huddleRouteEphemeralChannelId = + huddleTranscriptRoute?.ephemeral_channel_id ?? null; + const huddleRouteIsActive = huddleTranscriptRoute?.phase === "active"; + const huddleRouteDestinationChannelId = + huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + const huddleRouteMatchesLocation = Boolean( + huddleRouteDestinationChannelId && + location.pathname === `/channels/${huddleRouteDestinationChannelId}`, + ); + const isHuddleRoomStarting = + isHuddleRoom && + (!huddleRouteResolved || + !huddleRouteIsActive || + !huddleRouteMatchesLocation); + + React.useEffect(() => { + if (!huddleRoomChannelId || !huddleRouteResolved || !huddleRouteIsActive) { + return; + } + + let cancelled = false; + const channelId = huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + void Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ + queryKey: channelMessagesKey(channelId), + }), + queryClient.invalidateQueries({ queryKey: channelWindowKey(channelId) }), + ]).then(() => { + if (!cancelled) void goChannel(channelId, { replace: true }); + }); + + return () => { + cancelled = true; + }; + }, [ + goChannel, + huddleRoomChannelId, + huddleRouteEphemeralChannelId, + huddleRouteIsActive, + huddleRouteResolved, + queryClient, + ]); + + const handleHuddleStartPendingChange = React.useCallback( + (pending: boolean) => { + setIsHuddleStartPending(pending); + if (pending) setIsHuddleDrawerOpen(false); + }, + [], + ); + const handleHuddleVisibilityChange = React.useCallback( + (visible: boolean) => { + setIsHuddleDrawerOpen( + visible && !isHuddleStartPending && !isHuddleCompanionOpen, + ); + }, + [isHuddleCompanionOpen, isHuddleStartPending], + ); + const hideHuddleChannel = React.useCallback( + (ephemeralChannelId: string | null | undefined) => { + if (!ephemeralChannelId) return; + setRevealedHuddleChannelIds((current) => { + if (!current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.delete(ephemeralChannelId); + return next; + }); + }, + [], + ); + const trackHuddleBackingChannel = React.useCallback( + (ephemeralChannelId: string) => { + rememberHuddleBackingChannelId(ephemeralChannelId); + setHuddleBackingChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const revealHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + setRevealedHuddleChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const returnMainWindowToHuddleParent = React.useCallback( + (state: HuddleTranscriptRouteState) => { + const ephemeralChannelId = state.ephemeral_channel_id; + const parentChannelId = state.parent_channel_id; + if (parentChannelId) { + activeHuddleParentChannelIdRef.current = parentChannelId; + } + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + } + }, + [goChannel, location.pathname], + ); + const handleHuddleCompanionOpen = React.useCallback(() => { + const ephemeralChannelId = activeHuddleChannelIdRef.current; + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + const parentChannelId = activeHuddleParentChannelIdRef.current; + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + return; + } + + void invoke("get_huddle_state") + .then(returnMainWindowToHuddleParent) + .catch((error) => { + console.error("Failed to restore the huddle parent channel:", error); + }); + }, [ + goChannel, + hideHuddleChannel, + location.pathname, + returnMainWindowToHuddleParent, + ]); + const openHuddleCompanion = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + + if (huddleCompanionDismissedChannelIdRef.current === ephemeralChannelId) { + return Promise.resolve(); + } + + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + if ( + huddleCompanionChannelIdRef.current === ephemeralChannelId && + huddleCompanionOpenPromiseRef.current + ) { + return huddleCompanionOpenPromiseRef.current; + } + + huddleCompanionChannelIdRef.current = ephemeralChannelId; + const openPromise = invoke("open_huddle_window").catch((error) => { + if (huddleCompanionChannelIdRef.current === ephemeralChannelId) { + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + } + throw error; + }); + huddleCompanionOpenPromiseRef.current = openPromise; + return openPromise; + }, + [hideHuddleChannel, trackHuddleBackingChannel], + ); + const handleHuddleStarted = React.useCallback( + async (ephemeralChannelId: string) => { + try { + await openHuddleCompanion(ephemeralChannelId); + } catch (error) { + revealHuddleChannel(ephemeralChannelId); + throw error; + } + }, + [openHuddleCompanion, revealHuddleChannel], + ); + const viewHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + revealHuddleChannel(ephemeralChannelId); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelMessagesKey(ephemeralChannelId), + }); + void queryClient.invalidateQueries({ + queryKey: channelWindowKey(ephemeralChannelId), + }); + void goChannel(ephemeralChannelId); + }, + [goChannel, queryClient, revealHuddleChannel], + ); + const showHuddleInMainApp = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + viewHuddleChannel(ephemeralChannelId); + }, + [trackHuddleBackingChannel, viewHuddleChannel], + ); + const handleSidebarChannelSelect = React.useCallback( + (channelId: string) => { + if ( + isHuddleDrawerOpen && + channelId === activeHuddleChannelIdRef.current + ) { + showHuddleInMainApp(channelId); + return; + } + void goChannel(channelId); + }, + [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], + ); + const handleHuddleEnded = React.useCallback( + (ephemeralChannelId: string | null) => { + const endedChannelId = + ephemeralChannelId ?? activeHuddleChannelIdRef.current; + hideHuddleChannel(endedChannelId); + activeHuddleChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }, + [hideHuddleChannel, queryClient], + ); + + React.useEffect(() => { + if (isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + void listen("huddle-companion-returned", () => { + if (cancelled) return; + huddleCompanionDismissedChannelIdRef.current = + activeHuddleChannelIdRef.current; + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + setIsHuddleDrawerOpen(true); + void invoke("get_huddle_state") + .then((state) => { + if (!state.ephemeral_channel_id) return; + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + showHuddleInMainApp(state.ephemeral_channel_id); + }) + .catch((error) => { + console.error("Failed to open huddle in the main app:", error); + }); + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [isHuddleRoom, showHuddleInMainApp]); + + React.useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + void invoke("get_huddle_state") + .then((state) => { + if (cancelled || !state.ephemeral_channel_id) return; + activeHuddleChannelIdRef.current = state.ephemeral_channel_id; + trackHuddleBackingChannel(state.ephemeral_channel_id); + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + }) + .catch(() => { + /* lifecycle events remain authoritative */ + }); + listen("huddle-state-changed", (event) => { + if (cancelled) return; + if (event.payload.ephemeral_channel_id) { + activeHuddleChannelIdRef.current = event.payload.ephemeral_channel_id; + trackHuddleBackingChannel(event.payload.ephemeral_channel_id); + } + if (event.payload.parent_channel_id) { + activeHuddleParentChannelIdRef.current = + event.payload.parent_channel_id; + } + if ( + !isHuddleRoom && + event.payload.phase === "creating" && + event.payload.ephemeral_channel_id + ) { + void openHuddleCompanion(event.payload.ephemeral_channel_id).catch( + (error) => { + console.error("Failed to open starting huddle window:", error); + }, + ); + } + if (event.payload.phase === "idle") { + hideHuddleChannel(activeHuddleChannelIdRef.current); + activeHuddleChannelIdRef.current = null; + activeHuddleParentChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(false); + setIsHuddleStartPending(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + } + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [ + hideHuddleChannel, + isHuddleRoom, + openHuddleCompanion, + queryClient, + trackHuddleBackingChannel, + ]); + + return { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + isHuddleStartPending, + showHuddleInMainApp, + viewHuddleChannel, + }; +} diff --git a/desktop/src/app/useSettingsShortcuts.ts b/desktop/src/app/useSettingsShortcuts.ts index 0d0813a35e..757ddc65e1 100644 --- a/desktop/src/app/useSettingsShortcuts.ts +++ b/desktop/src/app/useSettingsShortcuts.ts @@ -5,7 +5,7 @@ import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; type UseSettingsShortcutsOptions = { onClose: () => void; onOpenSettings: () => void; - open: boolean; + open?: boolean; }; export function useSettingsShortcuts({ @@ -14,6 +14,8 @@ export function useSettingsShortcuts({ open, }: UseSettingsShortcutsOptions) { React.useLayoutEffect(() => { + if (open === undefined) return; + function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = hasPrimaryShortcutModifier(event) && diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..b39a7e8ec8 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -33,6 +33,7 @@ import { getManagedAgentLog, getRuntimeFileConfig, installAcpRuntime, + invokeTauri, listManagedAgents, listRelayAgents, saveCustomHarness, @@ -647,6 +648,12 @@ export function useAttachManagedAgentToChannelMutation( pubkey: result.agent.pubkey, }), ); + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: [result.agent.pubkey], + }).catch((error) => { + console.warn("Could not sync attached agent into Huddle:", error); + }); }, onSettled: (_data, _err, variables) => { // Invalidate the effective channel (the one the server actually mutated) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9003d0f5a5..8829edab39 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -14,6 +14,7 @@ import { joinChannel, leaveChannel, openDm, + invokeTauri, removeChannelMember, setCanvas, setChannelPurpose, @@ -517,6 +518,21 @@ export function useAddChannelMembersMutation(channelId: string | null) { return addChannelMembers({ ...rest, channelId: effectiveChannelId }); }, + onSuccess: (result, variables) => { + const effectiveChannelId = variables.channelId ?? channelId; + if ( + effectiveChannelId && + variables.role === "bot" && + result.added.length > 0 + ) { + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: result.added, + }).catch((error) => { + console.warn("Could not sync added agents into Huddle:", error); + }); + } + }, onSettled: async (_data, _err, variables) => { // Invalidate the effective channel (the one actually mutated) not the // live hook-closure channel, which may have changed mid-send. diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 7b9bf2b79f..2debd9e7a9 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -165,8 +165,8 @@ export function ChannelMembersBar({ members, }), ); - // Refetch channels so the new ephemeral channel appears in the sidebar immediately - // (default poll interval is 60s — too slow for huddle UX). + // Keep the channel cache current so the ephemeral transcript is + // available immediately if the huddle returns to the in-app drawer. void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (e) { console.error("Failed to start huddle:", e); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a30ee24114..cb0600a28a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -43,6 +43,20 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) { } } +export function isChannelCreatedSystemMessage(message: TimelineMessage) { + if (message.kind !== KIND_SYSTEM_MESSAGE) { + return false; + } + + try { + return ( + (JSON.parse(message.body) as { type?: string }).type === "channel_created" + ); + } catch { + return false; + } +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 770c45e445..54c0dcd5d9 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -48,16 +48,14 @@ import { WELCOME_PERSONA_ROTATION_MS, type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; -import { - isWelcomeSetupSystemMessage, - mentionsKnownAgent, -} from "@/features/channels/ui/ChannelPane.helpers"; +import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; +import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; import { Button } from "@/shared/ui/button"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -65,6 +63,12 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + +const HUDDLE_TRANSCRIPT_ROOT_STYLE = { + "--buzz-channel-content-top-padding": "0rem", + "--channel-top-chrome-height": "0.25rem", +} as React.CSSProperties; + export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -83,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({ hasOlderMessages, historyExhausted, isFetchingOlder, + isHuddleTranscript = false, followThreadById, isFollowingThread, isFollowingThreadById, @@ -191,13 +196,8 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear the ?autoSend search param once the auto-submit fires so - // back-navigation cannot re-trigger the send. - // When `onAutoSendComplete` is provided it does a surgical single-key clear - // that preserves `?thread` and all other panel search state (required for - // the thread-draft send path so the thread panel does not unmount before the - // deferred setTimeout(0) submit fires). The goChannel fallback is kept for - // callers that do not supply the prop (e.g. isolated tests / older wrappers). + // Clear only the auto-send key so thread state survives deferred submission; + // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -409,8 +409,6 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; - // Background card mints surface in the same rail ("Minting card…" chip), - // so they must also reserve the activity row. const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; @@ -444,7 +442,7 @@ export const ChannelPane = React.memo(function ChannelPane({ messageTimelineRef.current?.scrollToBottomOnNextUpdate(), }); }, [onAddAgent]); - const channelIntro = useChannelIntro({ + const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, onBrowseChannels, @@ -452,23 +450,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenMembers, onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); - const visibleMessages = React.useMemo(() => { - if (!isWelcomeExperience(activeChannel)) { - return messages; - } - - return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); - }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( - () => - buildMainTimelineEntries( - visibleMessages, - new Set(), - threadSummaries, - profiles, - ), - [profiles, threadSummaries, visibleMessages], - ); + const channelIntro = isHuddleTranscript ? null : standardChannelIntro; + const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -585,9 +574,14 @@ export const ChannelPane = React.memo(function ChannelPane({ isSinglePanelView, useSplitAuxiliaryPane, }); + const timelineReplyHandler = + activeChannel?.archivedAt || isHuddleTranscript ? undefined : onOpenThread; return ( -
- {!isSinglePanelView ? ( +
+ {!isSinglePanelView && !isHuddleTranscript ? (