Commit Graph

655 Commits

Author SHA1 Message Date
Luke Tornquist
ae689ab54d fix(desktop): resume reconnect replay from the last live point (#8652)
## Summary

A relay rolling deploy closes every socket with 1012. On reconnect,
desktop replayed each live subscription's **original filter** (startup
`since`, full `limit`) unless that subscription had received an event.
Quiet subscriptions therefore re-read history back to app start every
time. Paged channels did this twice: they resent the original filter
*and* ran the HTTP repair pages over the same window. A 1012 close also
reset the backoff to the 1s minimum, so a client bounced from pod to pod
during a deploy came back at roughly the same moment as every other
client.

**This PR makes three changes:**

1. **Resume cursor.** The subscription records `liveUntil`, when the
connection closes, if it reached EOSE on that connection. That time is
the last frame received from the relay, not the close itself. The
session tracks that time per peer frame, independent of the stall
watchdog, so a close delivered late (after a sleep, or while replay is
still running) doesn't overstate how long the subscription was live.
Close and error notices don't count as liveness. A frame processed more
than 70s after the previous one (the stall watchdog's idle timeout plus
its check interval) may have been queued through a sleep, so from then
on it doesn't extend liveness for the rest of that connection. Liveness
also starts fresh on every connection. If the time is unknown, the
previous `liveUntil` is kept rather than guessed from the clock. The
replay then uses `since = max(original since, lastSeen − 5s,
min(liveUntil − 960s, now − 3600s))`.
- **Why the one-hour window:** the relay can drop a fan-out frame for a
client whose send buffer is full without closing the socket
(`ConnectionState::send`), so continued pings don't prove a subscription
missed nothing. The liveness term therefore never resumes later than an
hour ago, which is the same window as the agent harness's silent-drop
repair (#8653). The saving applies to history older than an hour, so it
matters most for long-running clients, which is the deploy-bounce case.
Older drops still reach the timeline, because auto-heal refetches the
open channel's newest window on reconnect. What they miss are live-only
side effects such as unread and mention badges.
- **Why 960s:** the relay accepts `created_at` up to ±900s from its
clock (`MAX_TIMESTAMP_DRIFT_SECS` in
`crates/buzz-relay/src/handlers/ingest.rs`). An event stored after the
close can therefore be backdated by that much, and the extra 60s covers
client clock skew.
- **Gap floors win:** a pinned failed-backfill floor still takes
precedence.
   - **No cursor:** a subscription without one replays as before.
   - **Paged channels** keep their 1865s event-time lookback.
2. **Live-only REQ for paged channels.** When the HTTP repair covers the
gap, the live REQ goes out as the original filter with `limit: 0`. The
relay serves no stored events for it (`req.rs` passes the limit through
to a SQL `LIMIT 0`).
- **Backfill/live overlap:** the repair waits for that REQ's EOSE before
it starts. EOSE arrives only after the subscription is registered, so
live and repair always overlap.
- **If EOSE never arrives**, a single 25s deadline, measured from when
the REQ was sent, leads to the bounded history fallback below. The stall
watchdog runs during replay, so a half-open socket that never sends EOSE
or a close is reset by the watchdog rather than holding recovery.
- **A reset, disposal or supersession** wakes that EOSE wait
immediately, and the wait never starts for a retired connection, so a
dead attempt never holds `connect()` (and the next reconnect) behind it.
A `connect()` whose connection was replaced during replay settles as
superseded instead of arming the watchdog.
- **If EOSE never arrives, or all three repair attempts fail**, the live
REQ is re-issued under the same ID with the window's bounded history
(`since` = floor, `limit` ≤ 500): the pre-change behavior. The missed
window still fills on this connection, and the floor stays pinned for
anything the cap left out. Duplicate suppression stays on until that
request's own EOSE, so events live already delivered aren't dispatched
twice. If the original EOSE was never seen, a late one can't be told
apart from the fallback's, so only the deadline or teardown ends
suppression. Teardown also ends it, and so does a 25s deadline in case
the relay drops the EOSE. A subscription disposed while the fallback is
in flight still ends with a CLOSE.
3. **Reconnect spreading.** After a 1012 or 1001 close, the first
attempt waits a uniform random delay in [0, 10s] instead of resetting to
1s. Ordinary backoff jitter is a symmetric ±25%, capped at 30s. The
backoff resets only after the existing 60s stable-connection check,
never just because a socket opened.

**Simpler?** The backoff state, including its 60s stable-connection
reset timer, moved out of `relayClientSession.ts` into a small
`RelayReconnectBackoff` class in `relayReconnectPolicy.ts`. That makes
the jitter math testable on its own. It also keeps the session file at
1199 lines, the same as `main` (its cap is 1200), even with the new
liveness and replay logic; the liveness gap rule lives in
`relayResumeCursor.ts`. The stall watchdog's API is unchanged from
`main`.

**Identified debt (not changed here):**
- `relayClosedRecovery.ts` retries a retryable live CLOSED with the
original filter (1s → 30s backoff), with no terminal state.
- `useRelayAutoHeal.ts` sends an invalidation wave after reconnect.
- The `lastSeen − 5s` term keeps its existing gap for events backdated
after a close.
- `liveUntil` means the subscription was registered, not that every
event was delivered. The one-hour window bounds this: a dropped fan-out
frame older than an hour on a quiet subscription isn't re-read over the
websocket. The fix is a relay-side drop signal, the same follow-up as
#8653's.
- An HTTP repair page that is already in flight when the socket resets
still holds `connect()` until it returns, as on `main`. The native HTTP
client sets no request timeout.
- A `limit: 0` REQ still makes the relay acquire a reader for an empty
query.

### Related issue
N/A. This is part of a set of reconnect-load reductions. Companion PRs:
relay read-pool warm (#8651), agent harness (#8653) and mobile (#8659).

### Testing
- **Full desktop gate** on a Linux workstation at `045030c4`:
  - `pnpm typecheck`, `pnpm check` and the file-size check pass.
  - `pnpm test`: 6901/6901, plus 165/165 jsdom.
  - `pnpm build:e2e` passes.
- The `relay-reconnect` Playwright spec passes, including the restart
spread draw, which passed 10/10 repeats.
- **New or extended tests** (`node:test`):
- `relayResumeCursor.test.mjs`: a table test over the cursor inputs,
including the one-hour window, plus the liveness gap rule.
- `relayClientResumeCursor.test.mjs`: drives the real `RelayClient`. It
covers a dropped fan-out followed by pings, a frame queued through a
sleep, fresh credit per connection, a half-open socket reset by the
watchdog during replay, and a reset or disposal while parked on EOSE.
- Extended: `relayReconnectReplay` (deadline from send, a single timeout
→ fallback, a late original EOSE, fallback dedupe and CLOSE
convergence), `relayReconnectPolicy` and `relayClosedRecovery`.
- **Mutations:** each guard was reverted and failed at least one test.
That covers every cursor term, the 900 + 60 skew, the one-hour window,
`limit: 0`, the EOSE wait, its deadline and its timeout, floor
precedence, EOSE generation stamping, the last-inbound cap and gap rule,
the per-connection reset, the watchdog starting before replay, fallback
dedupe, the late-EOSE guard, the 1001 match, the backoff reset, the
restart spread and jitter symmetry.
- **Exercised against a real relay** (PG16 and Redis) in a relay-backed
desktop build, with Chromium on a Linux workstation, base `b310fea0` vs
head `045030c4`. Each channel was seeded with 200 reactions and 3
messages, all two hours old:

| | Base | Head |
|---|---:|---:|
| Quiet channel, stored events replayed over the websocket | 204 | 1 |
| Paged channel, stored events replayed over the websocket | 50 | 0
(`limit:0`) |
| Paged HTTP repair, events returned | 204 | 1 |
| Event-query calls / rows returned | 68 / 895 | 73 / 441 (−50.7% rows)
|

- **21-minute process freeze:** the watchdog fired first. The quiet
cursor matched `max(lastSeen − 5, min(preFreeze − 960, now − 3600))`
exactly, and 3/3 messages posted during the freeze were recovered once
each.
- **Drop before the paged EOSE:** `connect()` released in 13ms. The next
dial followed on the normal backoff, and the message arrived once.
- **Half-open socket** (all relay frames withheld, no close): the
watchdog reset it at 60.0s, the next dial came 1.2s later, and both
messages were recovered once each.
- **Earlier run at `180dc9ae`, before the one-hour window, relay restart
(1012):** the quiet channel resumed from last inbound − 960s. At this
head that cursor is also capped at now − 3600s. The paged channel sent
`limit:0`, with repair starting about 45ms after EOSE. Both messages
posted during the bounce were delivered, including one backdated 600s.
The first reconnect came about 8s after the close.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:50:33 -04:00
Brad Seiler
d6196a16c1 fix(relay): revoke invitations and restrict claims (#8237) 2026-10-28 18:42:49 -04:00
Matt Toohey
6861e18a86 fix(desktop): keep FileCard inside narrow message rows (#8612)
## Summary

Non-image attachment cards ran past their message row in the thread side
pane, and the download icon was clipped off.

`FileCard` is an `inline-flex` box, so it shrinks to fit its content and
is never narrower than its min-content width. That width comes from the
`truncate` (nowrap) filename. The only width cap was `max-w-sm` (24rem),
so a long filename pushed the card out to the full 24rem even when the
pane body was only about 300px wide (the default 380px thread pane).

- Add `max-w-full`, so the cap becomes `min(24rem, 100%)`. The flex
column ancestors already have `min-w-0`, so the percentage resolves
against the pane width. The card shrinks, the filename gets an ellipsis,
and the size and download icon stay visible. Wider surfaces (forum
posts, DMs, wide timelines) look the same because 100% is wider than
24rem there.
- Add a Playwright regression test in `file-attachment.spec.ts`. It
seeds a text attachment with a long filename and opens its thread. It
then checks that the card and its trailing icon stay inside the row and
that the filename is actually truncated. Without the fix, the card's
right edge lands about 82px past the row.

## Before / After

| Before | After |
| --- | --- |
| ![Before: in the 380px thread pane the attachment card runs past the
right edge of its message row, the filename is cut off mid-word, and the
download icon is clipped out of
view](https://github.com/user-attachments/assets/fca47df1-71c5-44d8-91af-a0b3085f1802)
| ![After: the attachment card fits inside the message row, the long
filename ends in an ellipsis, and the "13 KB" size and download icon are
both
visible](https://github.com/user-attachments/assets/3217b650-0296-4b11-942a-cf739056b680)
|

Measured in the default 380px thread pane: before, the card's right edge
sits at 1342px against a row edge of 1260px (+82px overflow); after, it
sits at 1252px (8px inside the row).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:40:28 +11:00
klopez4212
5fefec604f fix(mobile): restore channel icons and align chat colors with desktop (#8478)
## Summary
- Restores `#` and lock icons for public and private channels on Home,
with matching loading placeholders.
- Updates the dark-mode navigation colors to the desktop design-system
tokens (read and unread text, section headings).
- Replaces the chat color picker with the seven desktop accents (Gray,
Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map
to the closest one.
- Uses white text in your own bubbles, and black where white falls below
4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables
and code use the same text color.
- Insets code blocks in bubbles evenly, with corners concentric to the
bubble and even inner padding.
- The swipe-to-reply indicator uses the chosen chat color.

## Testing
- `flutter analyze` and `dart format` are clean. The full mobile suite
passes (3,705 tests).
- Installed and checked on a Pixel 10 and an iPhone Air against a real
community.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:12 -04:00
VSCteam
99924a5a69 fix(desktop): repair the Windows notification shim's false denial on read (#5600)
Addresses #2445. Closely related to #2483 -- see "Relationship to #2483"
below; I am happy to close this in favour of that one.

## The problem

On Windows the notification plugin's injected shim decides its
permission at startup like this
([`guest-js/init.ts:12-18`](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/notification/guest-js/init.ts)):

```ts
if (window.Notification.permission !== 'default' || __TEMPLATE_windows__) {
  return await Promise.resolve(window.Notification.permission === 'granted')
}
return await invoke('plugin:notification|is_permission_granted')
```

`__TEMPLATE_windows__` is replaced with the literal `true` on Windows,
so the `||` short-circuits and the shim compares its own
freshly-initialised `'default'` against `'granted'` -- `false` -- and
stamps **`denied`**. The line that would ask the backend is unreachable,
and the backend would have said yes: `permission_state()` returns
`PermissionState::Granted` unconditionally on desktop.

So on every launch of the Windows app, `window.Notification.permission`
is `denied` and nothing in the OS is denying anything.

## Why the toggle is only half of it

`getDesktopNotificationPermissionState()` trusts that value, so
`refreshPermission()` reports `denied` at mount, and this effect in
`hooks.ts` runs before the user touches anything:

```ts
React.useEffect(() => {
  if (settings.desktopEnabled && (permission === "denied" || permission === "unsupported")) {
    setSettings((current) => ({ ...current, desktopEnabled: false }));
  }
}, [permission, settings.desktopEnabled]);
```

It writes `desktopEnabled: false` and persists it. Measured on a real
install: with alerts successfully enabled and `desktopEnabled` persisted
as `true`, a clean relaunch comes back with it persisted as `false`.

That means fixing only the enable path leaves a Windows user re-enabling
alerts after every restart.

## What this changes

One place: `getDesktopNotificationPermissionState()` repairs the bogus
value instead of reporting it.

```ts
if (
  window.Notification.permission === "denied" &&
  isTauri() &&
  isWindowsPlatform()
) {
  try {
    return await requestDesktopNotificationAccess();
  } catch {
    return window.Notification.permission;
  }
}
```

`requestPermission()` is the only call that reaches the backend, and on
Windows it raises no prompt, returns `Granted`, and rewrites the shim's
cached value -- so this is self-limiting: the next read short-circuits
as `granted` and never requests again. There is a test for exactly that.

Repairing at the read rather than at the toggle fixes every consumer at
once, including `sendDesktopNotification()`, which gates delivery on
`=== "granted"` and would otherwise drop notifications at boot.

- `denied` stays terminal on macOS, on Linux, and on ordinary Windows
web pages.
- A denial that survives the request is still reported as `denied`.
- A rejected request falls back to the current value instead of
propagating; callers do not handle rejections today.
- Adds `isWindowsPlatform()` to `shared/lib/platform.ts`, alongside the
existing `isMacPlatform()` / `isLinuxPlatform()`.
- No new dependencies. No changes to `hooks.ts` or to notification
delivery.

## Verification on a real Windows 11 install

Buzz Desktop 0.5.9 release build, Windows 11 Home 10.0.26200, WebView2
151.0.4129.72, attached over CDP. Clean boot, nothing injected:

```
window.Notification.permission                -> "denied"
localStorage[...].desktopEnabled              -> false      (written by the effect above)

await window.Notification.requestPermission() -> "granted"
window.Notification.permission                -> "granted"

new window.Notification("Buzz", { body: ... })
ToastNotificationManager::History
  .GetHistory("xyz.block.buzz.app")           -> 3 -> 4     (real toast delivered)
CreateToastNotifier(...).Setting              -> Enabled
```

The OS was permissive throughout: no `ToastEnabled` override, no
notification policy keys under HKCU or HKLM, and the app present under
`HKCU\...\Notifications\Settings\xyz.block.buzz.app`. Full detail in
[this
comment](https://github.com/block/buzz/pull/2483#issuecomment-5255972424).

To be precise about scope: this confirms the mechanism the patch relies
on, measured on a stock release build. The patched build itself is
covered by the unit tests below, not by a packaged Windows run.

## Tests

`pnpm test` -- full desktop suite. New cases in `lib/desktop.test.mjs`:

- Windows Tauri repairs the shim's false denied state on read
- the repaired state is cached, so later reads do not request again
- a denial that survives the request is reported as denied
- denied stays terminal outside the Windows Tauri app (Windows web,
Linux Tauri, Linux web)
- granted is returned untouched and never triggers a request

Plus `shared/lib/platform.test.mjs` for `isWindowsPlatform()` across
`Win32` / `Win64` / `Windows` and `MacIntel` / `Darwin` / `Linux
x86_64`.

## Relationship to #2483

#2483 fixes the same root cause at the toggle: it retries the request
inside `setDesktopEnabled`. That makes the toggle work, which is what
#2445 reports, and its `ensureDesktopNotificationPermission` helper is a
nicer shape than an inline condition.

The difference is where the repair happens. Because #2483 leaves the
mount-time read as `denied`, the effect quoted above still turns the
setting off on every launch, so the fix does not survive a restart.
Repairing at the read covers the toggle path too, since
`setDesktopEnabled` calls `refreshPermission()` first and finds
`granted`.

The two overlap: both add `isWindowsPlatform()` to `platform.ts`, so
whichever lands second needs a trivial rebase. If maintainers prefer
#2483's shape, the equivalent of this PR is to call its helper from
`refreshPermission` as well as from `setDesktopEnabled` -- I am glad to
close this and send that instead. I did not want to push changes into
someone else's open PR uninvited.

## Once this lands

#2445 can close. I am running a per-machine launcher workaround for a
small Windows team until then, which this makes unnecessary.

Signed-off-by: VSCteam <144167621+Joxyko@users.noreply.github.com>
2026-10-09 15:19:56 -04:00
Will Pfleger
836e7d0d06 feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be
connected to. With #8011's community reads, the console can now browse
every community on the relay and, for signed staff, moderate any of them
by name without switching connections. 🤖

## What changes

- **Communities tab.** Lists every live community on the relay,
searchable by host prefix with "Load more" paging. The connected
community is pinned at the top by exact host match. Report rows get a
community badge that opens that community.
- **Community page.** Reports, Restrictions, Members and Actions, each
scoped to the community on the page. The Actions form has no host field:
it always acts on the page's community, and the TypeScript host
normalization is gone from the Actions tab.
- **Native reads** (`admin_list_communities`, `admin_search_members`,
`admin_get_member`, `admin_get_event`). Every request validates the
explicit community host and NIP-98-signs the real method and URL.
Failures come back as a structured error (`status`, `bodyComplete`,
`bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a
coded `event_not_found` and a transport failure apart, and never says
"not found" unless the relay did.
- **Explicit-host lifts.** `admin_lift_restriction` sends the page's
community host, not the active relay's. It freezes the relay and signer
the intent was reviewed under, and refuses to send if either has
changed.
- **Pending actions survive navigation.** A panel-level
`DirectActionsProvider` holds the frozen intent, so switching
communities or tabs mid-confirm doesn't lose it. Only Discard drops it.
A signer change remounts the controller and drops it.
- **Fenced lookups.** Member and event lookups are keyed by origin,
signer, community, target and a generation counter, so a late answer for
one community can't unlock Review in another.
- **Shared member picker** (`MemberSearchResult`, also used by Add
Member), showing the truncated npub so members with the same name can be
told apart. Delete accepts a note link and previews the message before
Review. Relay staff are blocked before Review.
- **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled`
the Communities tab and community pages still render, and report badges
still open them, but every ban, timeout, delete, add-member and lift
control is disabled. The settings badge says the console is read-only
and that acting needs `nip98`. Staffing stays hidden.
- #7904's protections in the Actions tab are kept: frozen intent,
`requestId`, Retry, `notSent` and the key-backup guard.
- **Readable load errors.** Admin list and detail loads show the relay's
error message (for example "request is not authorized"), never the raw
JSON envelope.
- **Failed kicks reload the report.** A resolve that fails with
`enforcement_failed` reloads the report, so its failed action and
"Cancel & reopen" appear immediately.
- **Key backups never leave in a search.** Every admin API request URL
passes the NIP-49 key-backup guard before it is signed or sent:
community and legacy reads, mutations, the connection probe (before its
first, unsigned request) and feedback-attachment fetches. The community
and member search boxes, including the directory's Load more, refuse a
pasted `nsec` or `ncryptsec` anywhere in the input without calling the
relay.
- **`just admin-seed` for multi-community testing.** Besides the local
community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50
filler communities, with named members, a ban and a timeout in each
named community, a report with Kick available, and a message for the
delete preview. Rerunning it restores all of that to the starting state.
It refuses, writing nothing, while a moderation action is unfinished on
a seeded report.
- The bodyless `GET /events/{id}` preview is added to the events-URL
egress inventory. It sends no body, so it carries no key material.

## Tests

Native (Rust):
- `empty_404_and_405_read_as_complete_and_empty`,
`coded_404_carries_the_relay_code`,
`truncated_and_over_cap_bodies_are_incomplete`,
`transport_failure_has_no_status`: the structured read error tells an
older relay, a coded refusal, a cut-off body and a transport failure
apart.
- `a_401_retry_is_signed_by_the_same_keys`,
`lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry
never switches keys.
- `read_urls_validate_the_explicit_host_and_encode_the_query`,
`lift_url_sends_the_page_host_not_the_active_relay`,
`list_url_carries_host_and_cursor_and_refuses_a_changed_relay`,
`lift_refuses_before_sending_on_signer_change_or_bad_host`: requests
target the explicit host and refuse to send after a relay or signer
change.

jsdom:
- `actions-pending-nav`: a pending intent survives a community change,
and only Discard drops it.
- `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key
never shows under, or unlocks, the new one.
- `actions-same-ids`: the same pubkey and event id are read separately
in each community.
- `actions-community-change-drops-member`, `actions-page-host`,
`actions-success`, `actions-identity`: the form acts only in the page's
community, and confirm sends the frozen intent.
- `actions-preview-errors`, `actions-delete-link`,
`actions-staff-target`, `actions-disabled-auth`: preview errors, delete
by note link, the staff block, and community pages rendering read-only
with every action control disabled under disabled admin auth.
- `communities-directory`, `communities-unsupported`, `community-badge`,
`community-badge-disabled-auth`: the directory, older relays, and report
badges, which open community pages under disabled admin auth too.
- `restrictions-lift-signer-change`. The existing restriction tests now
run on the community page and use `admin_lift_restriction`.

Mutation checks: removing the pending-navigation guard turns
`actions-pending-nav` red, and dropping the host from the fence key
turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red.
Ungating restriction lifts on `canMutate` turns `actions-disabled-auth`
red.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00
Will Pfleger
9c6fa14aa0 feat(desktop): offer Claude effort levels in agent edit and create (#8059)
Claude Code agents now get a thinking-effort dropdown in the agent Edit
and Create dialogs, matching the one buzz-agent and Codex agents already
have.

## Behavior
- **Where levels come from:** Claude effort levels come from the Claude
model data for the model that will actually run after Save, resolved in
order: explicit model, then inherited default (linked definition or
global), then adapter default. Stored Claude session levels are not
used, because the stored session data does not record which model
produced them.
- **Edit:** for an agent linked to a definition, effort follows the
definition's model, which stays authoritative. Save never sends a model
for linked agents.
- **Create:** effort is offered for local agents only and is stored on
the instance. Allowed levels are recomputed on every render, so
collapsing Advanced and then switching to a model without effort (e.g.
Haiku) drops effort from the request. Non-local effort is rejected
before any create side effects.
- **Persistence:** `create_managed_agent` sets the picked effort when it
builds the record and drops stale effort env aliases. Its body runs over
any Tauri runtime, so a mock-app test drives the real create path and
reads the record back from disk. That test runs in a child process with
temp app-data and nest folders, and a test-only switch turns keychain
use off so agent keys stay inline in the temp agent file and the OS
keychain is never touched.
- **Observer store:** live session-config captures are written to the
store before the dialog refreshes; archived captures are read-only.
- **Codex:** its existing session-native effort picker is unchanged.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-06 08:58:50 -06:00
Carlos Jimenez
fcf5f044b3 fix(desktop): use object-scale-down for inline markdown images (#4686)
Fixes #4384.

A dim-less markdown image with no cached decode gets
`DEFAULT_IMAGE_RESERVE = { height: 256, width: 384 }` and
`useFixedReserveBox: true` in `resolveImageReserveBox`. The frame is
that big on first view, and the inline `<img>` uses `object-contain`
which scales UP to fill the frame — so a 46×20 shields badge renders
as a giant blurry block. The same message renders at the natural
size on the second view because `rememberDecodedImageDimensions`
populates the cache, but the inconsistency between first and
subsequent view is the bug.

Switches `object-contain` → `object-scale-down` on
`ProgressiveImage.tsx`'s shared `IMAGE_CLASS`. Both `object-contain`
and `object-scale-down` scale down to fit, but only
`object-scale-down` also refuses to scale up — a small image now sits
centered in the reserve box on first view, never upscaled. The
layout-shift guarantee for large images is preserved (they still
scale down to fit the reserve), matching the issue's stated
constraint.

Chose option 2 ("Alternatively/minimally") from the issue over
option 1 because:

- It's a one-class-name change with zero new state, zero new
  effects, and zero risk to the existing layout-shift guarantee
  that the surrounding `useFrozenImageReserve` is explicitly
  designed to preserve.
- Option 1 (collapse the frame on decode) is the issue author's
  preferred direction but requires deciding whether the layout
  shift is acceptable *shrink*-only — a direction question that
  should be confirmed with the maintainer first. The issue
  explicitly says "Happy to open a PR for option 1 if the
  direction is acceptable" — that's the sign-off step, not
  this PR.

If maintainers want the full first-view-equals-second-view
behavior, option 1 is a follow-up that builds on this base.

Not added: a regression test. The current desktop test infra
(`desktop/src/shared/ui/markdown/markdown.test.mjs` and
siblings) doesn't cover `ProgressiveImage` directly; a
DOM-style snapshot would need a new test harness. The change
is one CSS class name; the visual regression is plain to
eyeball in the screenshots CI already captures.

Verified:
- `pnpm check` (biome + file-sizes + px-text + pubkey-truncation) —
clean
- `npx tsc --noEmit` — clean

Signed-off-by: kushaim <carlossilvajimenez@gmail.com>
Co-authored-by: kushaim <carlossilvajimenez@gmail.com>
2026-10-05 11:54:35 -07:00
Will Pfleger
83aab8cb5c feat(desktop): remove communities locally and stop their agent pairs (#7997)
🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:43:21 +00:00
Will Pfleger
bd0896f5c0 fix(desktop): read channel metadata and member lists from writer after writes (#7999)
🤖 This fixes two problems with the same cause. Since #3268, the relay
can answer an unpinned `/query` from the Aurora read replica, as long as
the replica has proved it's within `BUZZ_REPLICA_READ_MAX_AGE_MS` (1000
ms) of the writer. That's fine for display reads, but it doesn't
guarantee a client sees its own write.

**Problem 1: channel creation (Wes's report).** Right after creating a
channel, the desktop read back the channel's kind:39000 metadata by
`#d`. If that read landed on a replica that hadn't replayed the event
yet, it failed with `channel created but metadata not yet available`.
The channel existed, but the chosen template (canvas, agents) was never
applied.

**Problem 2: member lists.** After any membership change, the
member-list refetch could also hit a lagging replica. The relay returned
the list from before the change, and the desktop cached it as fresh for
five minutes (`CHANNEL_MEMBERS_STALE_TIME_MS`). Someone just removed
could still show as a member, or someone just added could be missing.

## Fix 1: metadata read-backs go to the writer

`channel_metadata_filter` is a shared builder that adds `"consistency":
"strong"`, so the relay serves the read from the writer. It follows the
same pattern as the canvas head reads. All four metadata read-backs that
follow a write use it: `create_channel`, `update_channel`,
`fetch_starter_channel_metadata` (its retry and fallback are unchanged)
and the open-DM refetch in `dms.rs`. A unit test fails if the flag is
removed from the builder.

The template's agent-add step also reads channel members (kind:39002)
right after create. `get_channel_members` takes an opt-in
`read_your_writes` flag that sends `"consistency": "strong"`. Only
`createChannelManagedAgents` sets it on that path, so the agent-add step
sees the creator's membership.

## Fix 2: a 31-second writer-read window after membership changes

Every membership write the desktop makes calls
`noteChannelMembershipChange` for each channel it changed:

- the native write wrappers `createChannel`, `openDm`, `joinChannel`,
`leaveChannel`, `addChannelMembers`, `removeChannelMember` and
`changeChannelMemberRole`
- the Bestie conversation open
- huddle agent add and remove, for both the huddle channel and its
parent
- `ensure_starter_channels` and `sync_agents_to_active_huddle`. Both
return every channel they created, joined or changed in
`changed_channel_ids`, including ones that landed before a later step
failed. The original error text is rethrown unchanged.
- live join, leave, removed and `admin_kick` system messages, and
member-added and member-removed notifications
- a report kick resolved from the Admin Console, once the relay confirms
it succeeded and only when the report's channel is in the active
community. The kick goes through the admin HTTP API, not a channel
event. Ban, timeout and their lifts only change `community_bans`, not
channel membership, so they need no record.

For 31 seconds after a change, measured on `performance.now()`,
`getChannelMembers` for that channel sends `readYourWrites`. Why 31
seconds: the relay caps its replica staleness budget at
`FENCE_STALENESS` (30 s), so once the window closes, no supported
configuration can serve the pre-change list. The constant's comment ties
it to `FENCE_STALENESS`. One app-level listener cancels any roster fetch
already in flight and refetches. When it registers, it also refreshes
every channel whose window is still open. All other member-list reads
still use the replica.

**Community switch.** The window map is cleared when the community
changes. Each awaited write captures a generation through
`beginChannelMembershipWrite()` before its first native call, and the
reset advances it. So a write that settles after a community switch
records nothing in the new community. Live events don't need this fence,
because the reset disconnects the relay client first and frames from the
old connection are dropped.

## Coverage

- A Rust test drives `ensure_starter_channels` against a stub relay that
accepts the creates but never serves metadata, and checks that both
created starter channels are still reported.
- Desktop tests cover the writer-read window, the Admin Console kick,
and writes that settle after a community switch.
- The desktop E2E test bridge (`desktop/src/testing/e2eBridge.ts`)
mirrors the strong reads and `changed_channel_ids`, so E2E runs exercise
the fixed behavior.

This doesn't address the smaller race in the relay's per-pod
accessible-channels cache, and it doesn't change mobile.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 11:32:16 -04:00
Will Pfleger
9b1c4f98e2 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854)
Long threads (over ~100 replies) time out in desktop. The aux hop that
loads reactions and other e-tag references builds its filter as one
`tags @> …` term per id, joined with `OR`. Past about 100 ids the
planner switches to walking each partition's primary key backward,
expecting to stop early. When few rows actually match, it scans the
whole community. With a dirty GIN pending list, a 188-reply thread never
finishes.

### What changes

- **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts
the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each
e-tag read also runs under a transaction-local `statement_timeout` of 20
s.
- **Terminal timeout contract (`buzz-relay`).** When a read hits the
deadline, the client gets a terminal timeout (`CLOSED` for REQ, the
timeout error for COUNT and search), not a retryable failure.
- **Subscription lifecycle (`buzz-relay`).** The per-connection
subscription map lock is now the lifecycle lock. Claims, replacement,
client `CLOSE`, owner-checked timeout and search cleanup, revoke, and
connection teardown all change the map, the registry, topic refcounts
and terminal frames while holding it. As a result, a timed-out
subscription's `CLOSED` goes out before any replacement with the same id
can claim it, and claims made after teardown are refused.
- **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED`
for a retired subscription (owner-checked timeout or channel eviction)
can't be queued, the relay cancels the connection instead of leaving the
client holding a subscription that will never receive fan-out. A
client-initiated `CLOSE` ack is exempt.
- **COUNT under the same deadline (`buzz-db`).** The fast COUNT path
runs e-tag counts under the same transaction-local 20 s cap as SELECT,
so WS COUNT and HTTP `/count` produce the terminal timeout too.
- **Replica reads surface the deadline (`buzz-db`).** When a
replica-routed read is cancelled by `statement_timeout` (SQLSTATE
`57014`), the error is returned instead of re-running the query on the
writer, which would run the same slow statement. Other replica errors
still fall back to the writer.
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception.
- **Deadline handling (desktop).** Only the relay's `query timed out`
counts as a query deadline. A client-side `request timed out` stays
retryable. For one-shot reads a deadline is terminal: the global query
retry (budget 1), thread-replies retry and reaction hydration don't
re-send the same slow query. Reaction ids held back by a deadline are
released when the channel is opened again, so reopening retries them
while same-channel re-renders don't. Live subscriptions, in both the
renderer and the native archive-sync socket, keep treating a deadline
`CLOSED` as transient: they back off and resubscribe with the same
filter instead of being dropped.
- **Deadline handling (mobile).** Mobile recognizes the deadline on both
transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED`
reason.
- The root `ProviderScope` retry policy returns no retry for deadline
errors and keeps Riverpod's default backoff for everything else.
  - Background thread-summary recounts don't retry a deadline.
- Forum posts polling pauses while the last load settled on a deadline;
reopening the forum retries.
- A channel's newest-window load surfaces a deadline instead of falling
back to legacy WS history, and the channel list's batched queries return
"unavailable" on a deadline instead of fanning out to per-filter
fallbacks. An unavailable batch is not treated as empty. Known
last-message timestamps and unread state are kept, and unread catch-up
moves `lastMessageAt` forward (never backward) the same way live events
do, so a cold start with no known timestamp still gets one.
- A live subscription that gets a deadline `CLOSED` backs off and
resubscribes with the same filter. History requests still fail on it.
- Settled load failures show a Retry control, matching desktop's
thread-replies error card: a shared `LoadErrorView` on the channel
timeline, forum thread and forum posts list, and an icon button beside
the thread replies summary. While the thread query has no value, the
thread page shows the route snapshot plus live, cached and optimistic
replies, with the loading or error status still visible.
- Other re-sends (reconnect, foreground resume, live events, the unread
backstop, scroll) are not suppressed; each is bounded by the relay's 20
s statement deadline.
- **Long-thread seeder.** `just seed-long-thread [replies]` posts one
thread (default 187 replies) with some reactions to the local dev relay,
for reproducing long threads in the apps. It uses a throwaway key,
refuses non-loopback relays and ignores ambient `BUZZ_*` credentials.
Writes are paced to fit the stock relay rate limit.

**Lifecycle concurrency.** The lock serializes one connection's
subscription changes. Other connections can still be delayed by pressure
on the shared pubsub command queue, and by serial eviction loops waiting
behind a blocked connection. This PR does not claim strict isolation
between connections.

**Recovery vs. load.** Clients stop automatic retries of the same
one-shot read, but they don't remember deadlines across other triggers:
reconnect, resume, live events and the unread backstop can each send the
read again later, bounded by the 20 s statement deadline. A live
subscription that hits the deadline resubscribes with its original
filter under the existing 1–30 s backoff, and against a filter that
keeps timing out it retries at the 30 s cap indefinitely, so the worst
case is one bounded query per subscription every 30 s. `since` is
deliberately not advanced to now on resubscribe, because that would skip
events missed while the subscription was closed. Unchanged from `main`:
when mobile's channel history sync fails with cached messages available,
the cached messages are shown without the error or Retry control.


### Benchmark

Local reproduction on an isolated bench stack (not committed):
- PG16, migrations applied, one 188-reply thread plus 5M background
events in the same community.
- Each request replays the desktop thread-replies filter through the
relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s
client cap.

GIN pending-list state is controlled with `VACUUM` (clean) or by
refilling the pending list (dirty), and recorded per partition with
`pgstatginindex`. Dirty runs held 402 pending pages on every partition's
tags index at the start and end of each matrix. Clean runs held 0–1
pages (relay startup writes).

| build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) |
|---|---|---|---|---|---|
| `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 /
639 / 641 |
| `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 /
27819 / 28080 | timeout ×3 (>60 s) |
| this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 /
150 / 135 |
| this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 /
2382 | 4333 / 4706 / 5104 |

Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag
read builders are identical to the current head. At head `1ad81d868`
against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean
(`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3).

Every completed cell returns the same signed event set: 209 events, 187
replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The
thread-window loader (`select_aux`) is unchanged from `main`, and its
timings and captured plans match `main`'s.

Actual aux-hop statements captured from each relay binary with
`auto_explain` (analyze + buffers), execution ms:

| build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188
ids |
|---|---|---|---|---|
| `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 |
| this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 |

**What the fence changes**
- On `main`, the planner combines `ORDER BY created_at … LIMIT` with an
overestimated match count and walks each partition's primary key
backward, expecting to stop early. With few real matches it scans the
whole community: the dirty 188-id statement touches ~4.9M shared buffers
on `main` vs ~0.6M with the fence.
- The fence prevents that early-stop plan. Across the measured cases
(1–188 ids, both pending states), the planner used the GIN tags index.
The fence doesn't force GIN in general; a broad enough filter could
still get a different plan.

**Tradeoff:** the fence materializes every match before sorting. In this
bench small limits stayed within run-to-run noise of `main`. A single
hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms
fenced.

**Deadline:** each e-tag read (SELECT and COUNT) runs under a
transaction-local `statement_timeout` of 20 s. It only tightens: a
shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's
a per-statement limit, not a per-request one, so a request that runs
several pages or hops can take longer than 20 s in total. The benchmark
does not force the deadline.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:36:40 -04:00
Will Pfleger
52c4c08c9e fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)
Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

Related: [#7806](https://github.com/block/buzz/pull/7806)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 12:48:16 -06:00
Will Pfleger
8f8c4dfade feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768)
Adds a relay admin console under Settings — the single surface for
relay-level operator work — replacing the previously unreachable
moderation section. Operators authenticate with their own Nostr key over
NIP-98 — no browser extension, no bearer token — to view deployment-wide
moderation reports and product feedback, resolve/dismiss/escalate/reopen
reports, update feedback status, and manage the operator/moderator
staffing roster.

## Nav reachability

The old `moderation` section id was defined in `settingsSections` but
never wired into any group in `settingsNavGroups`, so the sidebar never
rendered it — unreachable since #1617. This PR replaces it with a
`relay-admin` section wired into the Communities nav group, pointing at
the relay admin console, and drops the separate `admin-console` section
id so a single surface owns relay-level trust & safety.

The Relay admin nav entry is always visible. Auth gates the panel
itself; the nav entry is a door, not a credential — hiding it behind
discovery state would strand an operator whose relay transiently fails
NIP-11 discovery with no path back to the connection settings.

## Discovery and connection

On mount the console reads the connected relay's NIP-11 document for the
optional `admin_api` field. When a valid origin is discovered **and its
host matches the connected relay's host** (case-insensitive, host
identity only), it is automatically saved and probed — the panel renders
immediately with no Save step required. A cross-host advertisement is
pre-fill only: it populates the manual origin field under Advanced and
is never saved or probed without an explicit Save, so a relay cannot
direct an unconsented NIP-98 signature at a third-party origin; residual
exposure on the same-host path is bounded by NIP-98's URL/method/payload
binding. Saving is only required for manual origin changes. The
advertised origin is untrusted input: revalidated through
`AdminOrigin::parse`, falling back to manual entry when absent or
invalid. A saved manual origin always takes precedence over discovery.

## Auth and transport (Rust, `src-tauri/src/commands/admin/`)

- `AdminOrigin` value object: validates scheme + host + optional port,
rejects credentials/path/query/fragment; `http://` only for loopback.
The relay side normalizes its configured admin host at config load and
compares inbound `Host`/`Origin` hosts case-insensitively.
- `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so
the NIP-98-signed URL is byte-identical to the fetched URL.
- Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx`
is an error and the NIP-98 header never crosses origins.
- `admin_probe` signs `GET /probe` and returns a typed state
(`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` /
`networkOrIntercepted`). The response body is validated against the full
relay contract — `nip98Authorized` only when the complete NIP-98
invariant holds, `disabled` only on a coherent disabled-mode body; any
incoherent or unrelated JSON classifies as `notAdminApi`, so the console
never fails open at the network boundary. Unknown fields are tolerated
for forward compatibility.
- NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with
a fresh signed event; response size bounded by a `Content-Length`
preflight and a streaming byte counter; per-pubkey origin storage with
atomic `0o600` writes.

## Relay admin console

The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from
#3777; every `/api/admin/v1/*` route returns `403` to anyone else.
Community self-administration (`community-members` / Invites) and
in-channel enforcement (the 9040–9043 signed commands) are a separate
axis and are untouched. The previously-duplicate community report queue
(`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was
unreachable dead code and is deleted in its own commit;
`useBanMemberMutation` and the rest of `features/moderation/hooks.ts`
remain in use by the members sidebar and message menu.

## Console UI (TypeScript, `src/features/admin-console/`)

- `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for
every probe state, manual origin under an `Advanced` disclosure at the
bottom. The role (operator/moderator) is rendered in the connection
status line as plain text ("Connected as operator") — no badge-shaped
elements above the tab row. The origin provenance (relay config /
database) appears inside the Advanced card as small muted text. Section
title changed to `Admin`.
- `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing
gated on `role === "operator"`); reports and feedback grouped by
community for cross-community triage.
- **Reports**: always requests `scope=all` so the existing
resolve/cancel/reopen controls can reach every status (`open`,
`processing`, `resolved`, `dismissed`, `escalated`). The relay's
omitted-scope default is `escalated`-only (the platform-safety
backstop); `scope=all` is explicit and scoped to this console. Full
action matrix per `target_kind` (event →
delete/kick/ban/timeout/dismiss/escalate; pubkey →
ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is
suppressed when the report carries no `channelId`. A `processing` row
stays navigable — enforcement state lives in the detail view. A `failed`
action (always pre-mutation) offers a single **Cancel & reopen** via
`POST /reports/{id}/cancel` with `{actionId}` fencing; there is no
client-side retry. `pending`/`enforcing` actions belong to the relay
recovery worker and offer no button; a `409` reloads detail. Lists
refetch on back-navigation after a mutation.
- **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status
prefixes and surfaces the relay's own error reason in the toast (e.g.
relay returns `"400: you cannot report this content"` → toast shows
`"you cannot report this content"`). Self-report blocking UI removed —
the relay has no self-report gate, and the failure was purely
client-side error surfacing.
- **Reason audience disclosure**: the Reason (optional) field shows
exact copy under the input based on selected action — Delete discloses
that reason is sent verbatim to the affected user and posted publicly in
the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate
disclose reporter only. These mirror the actual relay notice paths.
- **Enforcement history**: the report detail carries the governing
action even after the report leaves `processing` — a report enforced
then reopened is `open` yet still shows its `succeeded` action as
executed history alongside the resolve form; a reopen does not un-happen
a ban. The resolve form gates on `open` status alone.
- **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can
be returned to `open` for re-triage via `POST /reports/{id}/reopen`.
Reopen never reverses applied enforcement, and the copy says so.
- **Feedback**: list, detail, and status control
(`new`/`reviewed`/`archived`) with a generation-fenced attachment
viewer. A purged source community severs provenance to `null` rather
than deleting the row; severed rows bucket under a "source community
removed" heading.
- **Staffing**: Add is create-only with display names resolved via
`useUsersBatchQuery` and npub cross-fade on hover
(`HoverStaffingIdentity`). An entered key that already appears in the
authoritative roster (config, owner-fallback, or DB) is rejected locally
with an inline duplicate-key message and no request is issued; a `409
Conflict` from add, role change, or remove surfaces the relay's parsed
error body via `adminErrorMessage(e)` — distinguishing config-backed key
conflicts from last-operator conflicts without HTTP-status inference.
Roster with `config`/`owner_fallback`/`db` source labels; config-backed
entries disable remove client-side. In-place role change via `<select>`
calling `putAdminOperator`; the relay responds with the effective
`OperatorEntry` (`pubkey`, `effectiveRole`, `sources`) in the same shape
as the roster list. Read-only badge for config-managed entries.
- **Timeout expiry**: `useTimeoutState` clears the store when derived
state is `INACTIVE` but the store is still active; the 1s tick is scoped
to entries with a known `expiresAtMs` and owned by
`ComposerTimeoutBanner` (mounted only while a timeout is active), while
`ChannelPane` subscribes to the boolean `useTimeoutActive()` — the pane
re-renders when a timeout starts or ends, not every second.
`useMembersSidebarModeration` ticks `nowMs` only while the sidebar is
open, the viewer can moderate, and some member has a future
`mutedUntil`; the tick after the last expiry stops the interval.
- Staffing refreshes the principal after a self-demotion or
self-removal: `AdminConsolePanel` passes `onSelfMutation` to
`StaffingTab`, which re-probes the saved origin so the role badge and
tab visibility update without a manual re-probe.
- Staffing mutation failures, including both 409 variants (config-backed
key vs. last operator), surface the relay's parsed error via
`adminErrorMessage`, so the last-operator recovery guidance is shown.
- Mutations confirm via `sonner` toasts; failures surface the relay's
parsed error `message`, not raw JSON. The `disabled` auth mode renders
read-only.

## Mutation idempotency

Resolve and reopen carry a per-attempt `requestId`. On first submit the
whole command (`{requestId, action, reason, expirationSecs}`) is frozen;
after an ambiguous failure (409, 5xx, transport error, truncated
response) the snapshot is retained, action/reason/duration controls are
locked, and the retry sends the exact same payload byte-for-byte. A
definitive pre-commit rejection (non-409 4xx with `bodyComplete: true`)
discards the snapshot, unlocks controls, and the corrected submit uses a
fresh payload. On success the toast derives from the authoritative
`AdminReportResolution` (`activeAction.action` when present; otherwise
`status` for decision-only outcomes) — never from the mutable form
selection.

The native mutation commands — including operator delete — reject with a
typed `AdminMutationError` carrying the relay's HTTP status
(`relayStatus`, `null` when no verdict exists) and a `bodyComplete` flag
that is `true` only when the full response body was read.

## Restriction routes contract change

`GET /members/restrictions` and `DELETE /members/{pubkey}/ban|timeout`
now take `communityHost` instead of `communityId`; the relay resolves
the tenant itself and returns `400 unknown_community_host` for an
unmapped host. This is a breaking contract change with no compatibility
shim: the relay must run this build before a desktop that uses it,
scripts sending `communityId` must switch, and rolling the relay back
breaks the desktop Restrictions UI. `docs/admin/README.md` documents the
three routes in the route inventory and carries the migration note.
Restriction calls also carry the relay the list was loaded from, and the
native side rejects a mismatch before building any URL.

## Hardening

- Saved admin origins are stored per pubkey and per relay host. The host
slug parses the relay URL, keeps the port-drop for DNS names, and
encodes IPv6 hosts canonically, so two IPv6 relays never share a file
and no IPv6 slug can match a DNS slug.
- NIP-11 admin discovery has its own 10s request deadline and reads
`/info` success and error bodies through the bounded reader with a 64
KiB cap (`Content-Length` is only a pre-check).
- `PUT /operators` builds its `OperatorEntry` response from the
validated body (`effectiveRole = role`, `sources = ["db"]`) instead of
re-reading the roster, so a concurrent change can't turn a committed
write into a `403`.

Related: #3777 (relay `OPERATOR`/`MODERATOR` role model + NIP-98 auth +
`admin_api` NIP-11 advertisement — provides the runtime and the
discovery field this console consumes)
Related:
[builderbot-platform-core-infrastructure#140](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/140)
(prod admin ingress allowlist and NIP-98 rollout)


## Screenshots

Captured headless at `197f0ba0c` against the mock bridge (1280×720).

### Setup

**Origin setup — Advanced disclosure open, idle state**
![Relay admin panel with Advanced: admin origin disclosure open before
probe](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/02-origin-setup.png)

**Authorized console — all three tabs**
![Authorized relay console showing Connected as operator status and
Reports/Feedback/Staffing
tabs](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/03-origin-authorized.png)

### Reports

**Reports queue — open, processing, resolved across two relay groups**
![Reports tab showing community-grouped list with spam/open,
harassment/processing, illegal/resolved
rows](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/04-reports-queue.png)

**Open report detail — reporter, target, message snapshot, resolve
form**
![Open spam report detail with reported message content and
Delete/Kick/Ban/Timeout/Dismiss/Escalate action
buttons](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/05-report-detail-open.png)

**Processing report — PROCESSING/HARASSMENT, ban enforcing state**
![Processing harassment report detail showing enforcement state with ban
action in enforcing
status](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/06-report-enforcement.png)

**Resolve form — Delete selected, audience disclosure copy**
![Resolve form with Delete action selected showing "Sent verbatim to the
affected user and posted publicly in the room" audience
copy](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/07-resolve-form.png)

**Resolved report — enforcement history block**
![Resolved/illegal report detail showing Resolved by, Resolved at, and
Act ID fields from completed
enforcement](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/08-resolved-history.png)

### Feedback

**Feedback list — community grouping with status badges**
![Feedback tab showing relay.example.com group with new/reviewed items
and source-community-removed
group](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/09-feedback-list.png)

**Feedback detail — mutable status control (operator mode)**
![Feedback detail with new/reviewed/archived status tab control visible
for
mutation](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/10-feedback-mutable.png)

**Feedback detail — passive status badge (read-only/disabled mode)**
![Feedback detail in disabled mode showing passive "Status NEW" badge
with no mutation
control](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/11-feedback-readonly.png)

### Staffing

**Staffing roster — config/owner\_fallback/db source labels**
![Staffing tab showing three operators with config, owner_fallback, and
db source
labels](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/12-staffing-roster.png)

**Add form — new pubkey filled, ready to submit**
![Staffing add form with a new 64-hex pubkey entered and moderator role
selected](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/13-staffing-add.png)

**Duplicate rejection — inline error for existing principal**
![Staffing add form showing "Already an operator: operator. Use Remove
to revoke before re-adding with a different role." error for an existing
config-backed
operator](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/14-staffing-dup.png)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-09-24 17:34:25 -04:00
Logan Johnson
dd1c2afc2a Discover alternate Buzz ACP commands (#6948)
🤖 I’m Larry.

## Summary
Managed-agent definitions can now choose which installed ACP transport
will launch the agent. The choice sits beside **Agent harness** while
creating or editing an agent, so it is made before first deployment and
remains visible afterward.

Buzz discovers executable commands named `buzz-*-acp`, keeps **Buzz ACP
(default)** as the safe default, and preserves existing custom command
values. Discovery and launch share the same resolver, so a command
offered in the picker is the command Buzz will execute.

## UX walkthrough

### 1. The ACP command picker appears while creating the agent
The default is explicit, and the helper text explains that this
selection controls deployment.

![Create-agent form with the ACP command picker beside Agent
harness](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6948/01-persona-create-acp-selector.png)

### 2. Installed wrapper commands are offered by name
Here Buzz has discovered `buzz-janet-acp` alongside the stock transport.

![ACP command menu offering Buzz ACP and
buzz-janet-acp](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6948/02-discovered-wrapper-dropdown.png)

### 3. The wrapper is selected before deployment

![Create-agent form with buzz-janet-acp
selected](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6948/03-wrapper-selected-before-deploy.png)

### 4. The deployed profile reports the effective command

![Deployed agent Runtime profile showing
buzz-janet-acp](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6948/04-deployed-profile-effective-acp.png)

### 5. Editing the deployed agent reopens its definition with the choice
preserved

![Edit-agent form retaining
buzz-janet-acp](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6948/05-edit-shows-persisted-wrapper.png)

## Details
- Definitions carry the selected ACP command through relay publication,
snapshots, imports, team adoption, deployment, and later edits.
- Existing definitions without this field continue to use `buzz-acp`.
- Unknown persisted commands remain visible but unavailable rather than
being silently replaced.
- Discovery is read-only and never executes candidates. It filters
non-files and non-executables, supports Windows `.exe`, `.cmd`, and
`.bat` shims, deduplicates aliases, and returns a stable sorted list.
- Command names remain portable; Buzz does not persist machine-specific
absolute paths.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@nostr>
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-24 11:33:24 -04:00
Will Pfleger
b6a26556ec feat(canvas): add version history with atomic restore (#6780)
🤖 Adds append-only channel canvas history, restore, and
relay-authoritative optimistic concurrency so stale edits cannot
silently replace a newer canvas.

## What this adds

- **Desktop history and restore** — browse revisions with author,
timestamp, preview, and diff; restore an older revision as a new signed
head after explicit confirmation. Persisted empty canvases remain
reachable, mutation outcomes are announced accessibly, and focus moves
to a stable destination after save or restore.
- **CLI history and restore** — `buzz canvas history --channel …`, `buzz
canvas get --channel … --revision <id>`, and `buzz canvas restore
--channel … --revision <id>` use keyset pagination over the retained
revision stream.
- **SDK canvas builders** — optional `expected-revision` tags, bounded
ancestry classification, and shared timestamp discipline keep
first-party writes ahead of the head they read without propagating
poisoned future timestamps.

## Authoritative concurrency contract

Tagged canvas writes use compare-and-swap semantics in the relay and
database:

- `expected-revision=none` succeeds only when no live canvas exists.
- An event ID succeeds only when it names the canonical live head.
- A byte-identical replay succeeds only when that event is already the
canonical live head.
- A successful candidate must sort strictly ahead of the current head
under `created_at DESC, id ASC`.
- Malformed preconditions and stale or non-advancing writes are rejected
before event rows, mentions, or fan-out are committed.

The database serializes tagged writes, unconditional kind `40100`
writes, and canvas soft deletion on the same `(community, kind,
channel)` advisory key. Untagged `buzz canvas set` remains an
unconditional write, but participates in that serialization boundary so
it cannot race a tagged save or deletion. Deletion classification is
derived inside the datastore transaction; unrelated event kinds do not
take the canvas lock.

## Read and timestamp consistency

Precondition and post-write ancestry reads request strong consistency
from `/query`, pinning them to the writer when replica routing is
enabled. Display-only history remains replica-eligible.

First-party canvas writers stamp `created_at = max(now, head + 1)` with
a conservative client skew limit. The relay applies a canvas-specific
future-timestamp ceiling before persistence, preventing an accepted
boundary head from making subsequent first-party writes invalid while
preserving the broader ingest limit for other event kinds.

## Durable outcomes and recovery

After an accepted publish, a failed verification read is reported as
durable success with `verified: false` rather than a failed save.
Desktop and CLI preserve the new event ID and direct users to History
when verification is unavailable. A successful read walks the bounded,
cycle-guarded `expected-revision` ancestry chain so transitive
descendants are not misclassified as supersession. Canvas caches
invalidate for every settled mutation outcome, including a retained but
superseded revision.

The 409 reconciliation path in `cmd_restore_canvas` now separately
establishes persistence evidence from ancestry classification.
`canvas_write_survived` checks reachability only; a secondary
writer-pinned IDs lookup distinguishes "A absent" from "A present but
superseded by a legacy write." The four-way outcome (survived /
persisted-but-superseded / genuinely-absent / read-fail) is now correct
for all lost-response shapes including unconditional legacy writes.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-09-23 11:03:54 -04:00
Wes
a024b83d54 fix(desktop): refresh channels after access-revoked closure (#7784)
Pinky is updating this PR on Wes’s behalf.

## Summary
- Treat exact `CLOSED restricted: channel access revoked` on a known
channel-scoped live subscription as a hint to refresh authoritative
channel state.
- Reuse `useLiveChannelUpdates`’ existing debounced, idle-aware query
refresh. Keep terminal retirement, explicit membership denial, and
polling unchanged.
- Cover archived, unchanged, removed, and failed authoritative results;
burst coalescing; irrelevant/stale frames; exact-reason rejection; and
unmount cleanup with seven production-path regression cases.

The hint does **not** establish archive or membership state. This closes
a missed-update recovery gap without changing the relay or blindly
retrying subscriptions.

Related symptom report: https://github.com/block/buzz/issues/7026 (not
claimed fully resolved; archived-channel delete policy is separate).
Related new-client fix: https://github.com/block/buzz-app/pull/106
Originating conversation:
buzz://message?channel=cb38cd5a-c920-40a1-b770-e9b89c289476&id=d22c1eed473016cf1f73c889a4cc88f1491ee092cfe3794575f8fea8003aa19e

## Review follow-up and main integration
At **`51ec7482ad1e600bd02ea6b9ed265db8a70e7266`**, based on main
**`c213d90d5c450e2e579505da4a4145f528ecbcf2`**:
- Addressed Jude’s P2: a known channel-scoped live subscription closed
with `restricted: not a channel member` stays terminal but must not
refresh channels.
- Mutation verification: replacing the exact reason predicate with
`startsWith("restricted:")` produced **6,588 passes / 1 expected
failure**, specifically the new no-refresh assertion (`actual: 1`,
`expected: 0`). Restored the exact predicate; the final full suite
passed **6,589/6,589**.
- Resolved both conflicts with #7790, retaining main’s paced/cancellable
subscriptions and this PR’s hint after terminal cleanup/drain
cancellation. The fixture advances the production drain with a
consistent virtual clock; it does not stub or bypass admission.
- Integration crossed the session file’s 1,200-line ceiling (1,207).
Extracted only this PR’s exact predicate/listener set into
`relayChannelAccessRevocations.ts`, with unchanged session ownership and
cleanup. No limit change, new timer, retry policy, or unrelated
refactor. Final feature diff: 46 production additions across three
files, plus the 333-line regression fixture.
- Independent read-only review of the integration and final extraction
found no blocking findings. This is not GitHub approval.

## Validation
At exact pushed head **`51ec7482ad1e600bd02ea6b9ed265db8a70e7266`**:
- Mandatory hooks passed without bypasses: desktop lint/policy checks,
TypeScript, repository file-size gate, branch-skew check, and full
desktop suite **6,589/6,589**, zero failed/cancelled/skipped.
- Working tree clean after push. DCO passed on GitHub.
- Fresh hosted CI and Desktop Release Candidate checks are running;
prior-head green checks are not evidence for this head. Re-review
requested from `jedwards27`; the changes-requested review and required
approval remain for the reviewer to update.

## Evidence boundaries
- Native IPC is stubbed; the fixture exercises actual subscription
creation, raw CLOSED dispatch, mounted hook, authoritative query
refresh, and IPC result conversion. It does not exercise native
discovery or rendered sidebar behavior. Existing in-flight refresh
retention remains source/helper-test coverage rather than a new
integrated case.
- No native app launch, live archive mutation, or release certification.
This fixes a demonstrated recovery mechanism, not proof of the exact
original incident ordering.
- The initial broad local `just ci` attempt hit a 300-second timeout
compiling `buzz-voice`; full local `just ci` remains incomplete. Current
delivery uses mandatory path-scoped hooks and fresh hosted CI; no
unrelated native/mobile lane was bypassed or claimed rerun locally.

---------

Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
2026-09-22 15:55:49 -06:00
Wes
c213d90d5c fix(desktop): bound startup request bursts and recover quota refusals (#7790)
Prepared by Brain on Wes’s behalf.

## Summary

Reduce startup request amplification and let quota-refused background
work recover without changing relay quotas or bypassing the existing
Send gate.

- Coalesce nest regeneration into one worker plus the latest pending
generation. Keep the stale-write fence; bound the archive read so an old
workspace cannot strand newer work.
- Claim pending channel subscriptions before awaiting setup; cancel
removed/unmounted entries and preserve still-owned subscriptions across
reconnect handoff.
- Recheck extended cooldown deadlines before retry dispatch; distinguish
an explicit zero-second hint from a missing hint.
- Recover native finite/unread queries within their original caller
deadline and a three-retry budget, preserving verified unique events,
complete filters and terminal errors.
- Pace renderer initial/live-CLOSED REQs at one per 250ms: visible
channel and explicitly interactive TTS/read-state/observer owners first,
then live-only auxiliaries, then background history. Publication and
finite queries bypass this queue. Reconnect replay now promotes explicit
interactive owners alongside the visible channel with stable
registration-order ties; its existing capped batches and cooldown remain
intact.

**Scope at `867495426`:** 39 source/test files, including 15 production
files (+665/-235) and 24 test/fixture files. The diagnostic recorder,
HTTP instrumentation, trace launcher and raw captures are deliberately
excluded. No profile reset, grouping, quota increase or new global
scheduler.

## Before / after evidence

These are observations from instrumented, human-operated development
Tauri/Vite runs against a production relay, plus explicitly identified
controlled regressions—not a packaged-release startup benchmark.

| Measurement | Before | After | Evidence boundary |
| --- | --- | --- | --- |
| Archive snapshot requests (kind 13535) | 290 | 3 | Recorder-only
baseline → nest-only intermediate candidate; unequal 67.436s / 39.273s
captures and hydration differences |
| Archive responses: HTTP 200 / 429 | 277 / 13 | 3 / 0 | Same
baseline/nest-only captures; not a claim that all quota pressure
disappeared |
| Successful archive responses’ advertised content-length sum |
21,208,505 bytes | 229,695 bytes | Header values, not actual transferred
bytes |
| 154-channel StrictMode replay | 308 REQs | 154 REQs | Controlled real
React hook → real session → mocked Tauri transport regression |
| Add one channel while 154 setups are pending | 309 REQs | 155 REQs |
Same production-boundary regression; ordinary mount remains 154 → 154 |
| Broad renderer request burst / spacing | 110 broad REQs within
10.766ms in pre-drain capture | 250.061ms minimum / 252.131ms median
between broad REQ writes in final longer capture | Different attended
runs; pacing rather than a universal request-rate cap |
| Native unread query outcomes | Baseline: 8 EOSE / 146 quota CLOSED out
of 154 queries | Final longer run: all 156 observed queries reach EOSE;
eight refused queries each recover after two refusals | Different
runs/workloads; query completion, not badge rendering |

### Timing metrics

| Observation | Measured value |
| --- | ---: |
| Baseline’s two accepted chat EVENT socket writes → positive OK |
173.340ms / 85.556ms |
| Nest-only intermediate candidate’s two accepted writes → positive OK |
277.854ms / 78.775ms |
| Final candidate, short run, accepted chat writes → positive OK |
80.636ms / 77.506ms |
| Final candidate, longer run, accepted chat writes → positive OK |
120.712ms / 86.895ms |
| Last initial EOSE among 163 observed broad channel registrations,
longer run | 64.550s after trace start |
| Last EOSE among 156 observed native unread queries, longer run |
73.972s after trace start |
| Native retry waits after 3s then 0s hints, longer run |
3000.7–3002.8ms, then 2001.5–2002.6ms |
| Renderer finite-query retry after a 2s hint, longer run | 2061.365ms |

Both Sends in each final run were accepted, with no recorded negative
OK. Every quota-refused query in the longer run subsequently completed
the same query. Its two Sends happened early (17.878s and 21.655s), not
near the end. These small samples do not support a percentage latency
improvement.

**Tradeoffs and limits**
- 296 background subscriptions imply about 74s of paced registration
before extra cooldowns/high-priority work. The longer capture observed
163 registrations; it did not independently enumerate the expected
membership set or record a queue-empty marker.
- This is not a global 4-REQ/s cap: finite queries, native requests and
reconnect replay are outside the drain and still share relay quota.
Quota refusals recover rather than disappear.
- Socket-write → OK starts after composer submission and any publisher
gate wait. No click-to-visible, launch-to-usable or badge-paint latency
is claimed.
- Native EOSE is not proof of command return, classification, React
state application or visible unread badges. Existing per-channel kinds,
since floors, limits, h-less routing, unread lease/scope guard and
eight-query concurrency remain intact.
- The attended measurements belong to the instrumented candidate, not a
fresh release run of this fixes-only commit. At the initial packaging
checkpoint (`1bf3d060`), eighteen of twenty files were byte-identical;
the other two removed diagnostic gate calls and isolated the nest test’s
temporary paths. The subsequent interactive priority/reconnect and
readiness fixes have controlled hook/session/browser evidence, not
another attended voice/performance run.

### Evidence provenance (captures retained locally, not uploaded)

| Capture | SHA-256 |
| --- | --- |
| Baseline `STARTUP_20260921T194814Z_98811.jsonl` |
`f4f0a89ad9aea91084bd6a7432510488166de4cbc906600d88c26d557f380994` |
| Nest-only `STARTUP_20260921T202610Z_28642.jsonl` |
`ce517b02a3f717c36d96b36bb8616e8fa80b03b12b356bacfd40475925ea3f3e` |
| Final short `STARTUP_20260922T013628Z_13506.jsonl` |
`bfea6f3cf80cd7b7e4e676632a3df2f16271fade5bfa0edec72b7bcbbe7839ff` |
| Final longer `STARTUP_20260922T014637Z_15846.jsonl` |
`f4abece613d108c4c2fe3cb808e633a0486bdc9993ca92323822dc00c3fae6ec` |

Final instrumented build fingerprint:
`b0b4fc27d793c3e643f4e10289b5afdb4620ee2e13a8b33fa0595dd3417fd5ca`. All
observed dropped-record counters in these final captures were zero.
Source manifests and offline correlation analyses are retained locally;
no raw captures, profile data or agent sessions are part of this PR.

## Validation

Current head: `86749542657a60abdfe82eed5cc576d3e162fe5d`, based on
`77729abfb692b25a0f4ec4a69add86af2e32c0dd`.

Review follow-ups:
- `8842dbd8`: explicit interactive TTS admission using the existing
main-window owner, independent of companion/timeline selection.
Real-hook/session/IPC coverage with 296 cold owners proves initial and
CLOSED priority, unchanged filters/cooldown/pacing, background progress
and deduplicated speech.
- `8cb088106`: reconnect preserves interactive priority even with no
visible channel. Stable ordering among foreground ties; real
hook/session reconnect regressions cover cooldown, capped batching, all
297 owners, deduplication and unmount cancellation. Independent reviewer
mutation restored the prior replay implementation, failed both reconnect
rows, then passed the restored candidate.
- `68e76d7c`: read-state and observer reuse interactive priority. Stop
and ModelPicker wait on the existing observer owner inside send
callbacks, after installing result listeners/deadlines. No new owner,
timer or scheduler; inherited CLOSED/fallback/error semantics remain,
rather than a new strict EOSE guarantee. The real Stop panel browser
probe holds observer IPC across the click and checks
observer-before-control order and correlated outcome. ModelPicker
component ordering is source-reviewed only.
- Mock live admission now replays matching stored history with
per-filter time/limit constraints, deduplication, untagged auxiliary
channel fallback and authoritative explicit h-tags. Kind-specific
fixture admission waits and retry-resolving hover actions fix
startup-dependent test setup without weakening event/badge assertions.
- `48e9c0dad` and `867495426`: steady-state scroll-prepend setup
observes live admission, subsequent head dispatch, successful/idle query
completion and initial bottom geometry. Phase-2 genuine-growth and ≤32px
anchor assertions are unchanged. This isolates steady-state prepending:
it **does not fix or certify production early-scroll/head-reset
behavior**.

Current validation:
- Normal push hooks at `867495426`: **6,582/6,582 renderer tests**, zero
failed/skipped/cancelled; lint/static checks, typecheck and differential
file-size gate passed. Native source is unchanged from the earlier
checkpoint; no new native run is claimed for these renderer/test
follow-ups.
- E2E build passed at `48e9c0dad` plus the exact subsequently committed
test-only delta (spec blob `469be2eed868c677ba80b859cc871208000fee60`).
The completed affected-smoke run passed **153/153** across
agent-control, badge, entity links, messaging, profile status and scroll
history; the full scroll file additionally passed twice (**36/36**). No
full CI-smoke pass is claimed from this six-file local run.
- Pinky independently reviewed production and test follow-ups, including
the final scroll spec blob `469be2eed868c677ba80b859cc871208000fee60`,
with no blocking finding. Review is source evidence, not an independent
test run.
- **Not merge-ready:** new-head required CI must complete, exact-range
security review requires authorization, and GitHub currently reports
REVIEW_REQUIRED; fresh exact-head reviewer approval is still needed. No
approval, dismissal or merge performed. No new packaged/native audio,
launch-to-usable or badge-paint observation is claimed.

Initial fixes-only checkpoint validation
(`1bf3d06024d284a63326fdd353baced0842aeb81`):

- Pre-commit formatting hooks passed; worktree clean.
- Renderer package hooks: 6,570 tests passed, zero
failed/skipped/cancelled; lint/static checks, TypeScript and
differential file-size gate passed.
- Native package hook passed: default and mesh-enabled all-target
Clippy, then the complete native workspace test suite. App library:
3,222 passed / 0 failed / 19 ignored; terminal/integration suites
passed. The real nest trigger/stalled-body test passed on macOS.
- Independent whole-change source review: no blocking findings at this
commit; all 20 file hashes matched the committed blobs and
review/validation manifests.
- Regression coverage includes real hook/session cancellation,
StrictMode/membership re-entry, reconnect ownership, extended/zero
cooldowns, burst pacing, auxiliary readiness (including EOSE before IPC
settles), native socket refusal/retry/deadline/cancellation, unread
fetch/classification and real nest trigger/HTTP/file persistence.
- Earlier isolated renderer mutations that started readiness before
enqueue or removed auxiliary priority each failed the real presence
regression; restored controls passed. No native mutation claim.
- Nest fixture uses explicit app-data/nest paths instead of assuming
Windows known-folder APIs obey HOME/APPDATA. Windows runtime execution
is not claimed; CI/build results remain separate.

Originating conversation:
buzz://message?channel=46ced308-71df-495d-a144-4ebf08334703&id=bc43c3422a3b0b2141268d0fa149b898421c824992b662aed8b73c4eee50404f

No approval or merge recommendation is implied by opening this PR.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-09-22 18:31:44 +00:00
Wes
8953cbfff5 fix(desktop): keep managed agent avatars usable across communities (#7732)
## Summary

Fix Desktop-managed agents losing avatars after joining another
community. The saved persona/instance stores one desired source URL, but
authenticated media belongs to a community. Republishing community A’s
URL in B does not make that image accessible in B.

- Before comparing or publishing kind-0, reuse or copy
configured-community avatar media into the pinned destination. Keep
exact image bytes, including animation, and leave the saved source
unchanged.
- Keep the previous profile on transfer/publication failure so existing
reconciliation can retry. Reuse destination bytes during source outages;
after source removal, reuse only an already-published signed destination
picture with the same original content hash.
- Refresh source permissions through narrow, serialized IPC without
reconnecting or resetting the active community. Startup waits until the
latest permission update completes before restoring agents.

### Related issue

Refs #2366 (avatar portion only, not runtime availability). Related
#2659 deliberately excludes avatar/media mirroring; this change
addresses that media boundary without introducing runtime fan-out.
Searched existing issues/PRs for `avatar community` before opening.

## Contract and safety

Desktop-managed profiles still follow the saved persona source; this
does not introduce community-specific persona editing. Agent-managed
profiles still opt out of automatic reconciliation.

Authenticated transfer requests use the fixed agent key/NIP-OA tag,
origin-scoped Blossom auth, and the pinned destination, never mutable
active-workspace owner credentials. They refuse redirects, enforce
30-second request timeouts and bounded bodies, verify SHA-256 and
detected image MIME, and validate the returned descriptor’s
origin/hash/MIME/size. Source permission is rechecked after an awaited
destination HEAD miss. The removed-source fallback verifies the
destination profile’s signature, kind, signer, origin, and
original-media hash without contacting the removed source.

Ordinary external images retain passthrough behavior and receive no
community credentials. No public-media policy change, image-reader URL
rewriting, new persistence, background service, or global profile merge.

## Testing

Normal commit and push hooks passed on exact head
`925781eece66d5f6a4b084b3b3abd6309cda357e`; working tree clean before
and after:

- Desktop formatting/lint, typecheck, and **6,508 tests**.
- Full native workspace suite, including **3,204 desktop-library tests**
(19 ignored), integration tests, terminal crates, and doc tests.
- Native Clippy on default and `mesh-llm` workspace/all-target
configurations with warnings denied; repository file-size and
branch-skew gates.

Regression coverage exercises production reconciliation/shared
publication against two local HTTP fixtures: add/edit/restart, upload
and rejected-publication retry, removed-source local-image preservation,
fixed signer/destination, redirect/hash/descriptor/size failures,
revocation during pending HEAD, and opt-out no-I/O. Mounted
initialization tests cover inactive-list changes without workspace
reset, two deferred permission updates, and rejection of either update.

Earlier working-tree validation at base
`4ab4f786085a23fe6126529861840eff6048ceee` also passed the E2E frontend
build and four Chromium mock-bridge community flows (boot, switch,
active removal/fallback, leave-final/setup). Subsequent production
changes only serialize permission writes and wait for a stable latest
update; the complete suites above cover the final commit. Mutations
independently removing serialization or stable waiting failed the new
regression.

## Remaining validation and limitations

- No live production avatar was changed. Native GUI rendering against
two hosted communities remains to be verified with a build containing
this fix; local HTTP fixtures and the browser mock bridge do not
establish that outcome.
- A removed/unreachable source with no existing destination copy cannot
be recovered automatically. Choose a reachable image/source in that
case.
- This is a publication/reconciliation fix, not a visual redesign. No
before/after native GUI screenshots are claimed. Broad repository CI
remains required; it was not duplicated locally.

## Review

Independent security/scope and caller/ordering reviews are clear after
the bounded fixes. Final hook-ordering reread covered the staged tree
that became this commit. Commit authorship/sign-off use the implementing
Carl identity and configured Carl signing key.

Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-09-18 06:14:28 -06:00
klopez4212
81fba7bda4 Refresh desktop onboarding flow (#7528)
## Summary
- apply the refreshed card layout across desktop onboarding
- update identity backup, recovery, and key presentation flows
- clarify AI connection choices and default model settings
- simplify the backup ceremony from creation through optional
verification
- bring the harness and provider setup flow forward with validation,
preserved state, and consistent navigation
- fix action alignment and stray card scrolling in the refreshed layout

## Validation
- pre-push desktop lint, typecheck, frontend tests, and native tests
passed
- focused onboarding Playwright coverage passed
- manually reviewed the refreshed onboarding and backup flow in the
native app
- just ci passed all sections except one unchanged process-cleanup
timing test; its exact rerun passed

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Clay Delk <clay.delk@gmail.com>
2026-09-15 05:58:16 -04:00
Salman Mohammed
6c35e82bd5 fix(acp): improve hints in pi agent setup guide (#7594)
## Summary

Handle missing Pi adapter setup and update its install guidance.

### Related issue

None found.

### Testing

No manual testing.

Generated with Amp

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-09-12 17:40:15 +00:00
Salman Mohammed
78618804ec Configure ACP session scope per agent (#7578)
## Summary

PR #6732 taught the ACP harness to honor `BUZZ_ACP_SESSION_POLICY`, but
the desktop exposed that policy as one global experiment. Turning it on
changed every managed agent at once, even though conversation scope is
part of how an individual agent should behave. Some agents need
continuity across a channel, while agents such as validators may need
clean context for each thread.

This moves the choice onto the agent definition under Advanced as
**Conversation context**, with **Entire channel** and **Each thread**
options. **Entire channel** remains the default, so existing definitions
keep their current behavior.

The policy now travels through create and update IPC, returned agent
summaries, persona events, snapshots, teams, and community catalog
copies. Launches pass the effective definition value to the harness.
Editing a running agent uses the same lifecycle as a model edit: it
shows the restart-required state, leaves the deployed process untouched,
and applies the new policy on restart.

The global Thread Scoped ACP Sessions experiment and its desktop
command/state have been removed because the setting no longer has a
useful global meaning. There is intentionally no migration for the old
preview override. Users who enabled it can choose **Each thread** for
the agents that need it.

Unknown or null policy values degrade to **Entire channel** across
stored agents, persona events, and catalog projections. This keeps
forward-version data from dropping complete agent records.

### Related issue

No matching issue found. Builds on #6732.

### Testing

- Focused Playwright create flow selected **Each thread** and verified
`create_persona` received `sessionPolicy: "thread"`.
- Focused Playwright catalog flow added a thread-scoped agent and
verified the local copy stored `session_policy: "thread"`.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-09-11 20:38:41 +00:00
Taylor Ho
ec11f8e2f3 fix(avatars): scale agent squircles from normalized paths (#7307)
## Overview

**Category:** fix

**User Impact:** Agent avatars and empty agent-team placeholders now
keep the same intentional squircle silhouette at every size on desktop,
while human avatars remain circular.

**Problem:** Desktop agent avatars used percentage-rounded rectangles,
which made the silhouette vary by surface and required each avatar
implementation to recreate the shape. Empty team placeholders also drew
a regular CSS border inside the clip, leaving straight inset edges that
looked like a cropped square. **Solution:** Reuse one normalized SVG
clip path across desktop artwork. Empty team placeholders use a
separately clipped squircle outline behind the clipped surface, so both
the fill and outline follow the canonical silhouette.

> **Scope correction:** Mobile is deferred from this PR because a real
Flutter capture could not be obtained reliably in the current build
environment. Previously attached mobile images were hand-composed
synthetic layouts, not captures from the running app; they have been
removed and must not be treated as validation. The mobile implementation
is being reverted in one follow-up commit so it can be reapplied and
verified separately.

### Related issue

Follow-up to #7106.

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/agents/ui/AgentCreationPreview.tsx**
Applies the shared squircle clip to agent creation preview images and
fallbacks.


**desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx**
Uses the shared agent-avatar silhouette in compact session summaries.

**desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx**
Clips agent initials with the normalized squircle instead of a
percentage radius.

**desktop/src/features/agents/ui/TeamIdentityCard.tsx**
Uses the shared squircle for agent identities and gives empty teams a
separately clipped squircle outline instead of a regular inset border.


**desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx**
Keeps agent avatars in activity message bubbles on the canonical
silhouette.

**desktop/src/features/home/ui/InboxListPane.tsx**
Applies the shared clip to agent avatars in the inbox list.

**desktop/src/features/home/ui/InboxMessageRow.tsx**
Applies the shared clip to agent avatars in inbox message rows.

**desktop/src/features/huddle/components/ParticipantList.tsx**
Uses the normalized squircle for agent participants while preserving
circular human avatars.

**desktop/src/features/messages/ui/MessageRow.tsx**
Clips agent message avatars through the shared definition.

**desktop/src/features/messages/ui/SystemMessageAvatars.tsx**
Uses the canonical agent shape in system-message avatar groups.

**desktop/src/features/messages/ui/TypingIndicatorRow.tsx**
Uses the canonical agent shape in typing indicators.

**desktop/src/features/profile/ui/ProfileAvatar.tsx**
Scales the same squircle to the larger agent profile avatar.

**desktop/src/features/profile/ui/SelectedRecipientChip.tsx**
Uses the shared shape for selected agent recipients.

**desktop/src/features/projects/ui/IssueAssigneesRow.tsx**
Applies the agent squircle to issue assignees without changing human
avatar geometry.

**desktop/src/features/projects/ui/ProjectCards.tsx**
Uses the canonical shape for agent avatars on project cards.

**desktop/src/features/projects/ui/ProjectEntityListRow.tsx**
Uses the canonical shape for agent identities in project entity lists.

**desktop/src/features/projects/ui/ProjectsActivityFeed.tsx**
Uses the shared clip for agent avatars in project activity.

**desktop/src/features/projects/ui/ProjectsOverviewRail.tsx**
Uses the shared clip for agent avatars in the project overview rail.

**desktop/src/main.tsx**
Mounts the single shared SVG clip-path definition once for the desktop
application.

**desktop/src/shared/lib/cn.ts**
Registers `rounded-squircle` in the border-radius class group so
shadcn-style `cn(...)` composition resolves it against `rounded-full` by
normal last-class precedence.

**desktop/src/shared/styles/globals/utilities.css**
Registers the reusable Tailwind `rounded-squircle` utility and
references the normalized SVG clip path without agent-specific CSS
selectors.

**desktop/src/shared/ui/AvatarClipPaths.tsx**
Owns the reusable normalized object-bounding-box `rounded-squircle` path
so all desktop sizes reuse one definition.

**desktop/src/shared/ui/UserAvatar.tsx**
Routes the existing squircle shape option through the shared SVG clip.

**desktop/tests/e2e/agents.spec.ts**
Verifies team facepile and empty-team placeholder surfaces/outlines use
the canonical clip without a rectangular border.

**desktop/tests/e2e/messaging.spec.ts**
Verifies agent message and profile avatars reuse one clip definition and
scale across rendered sizes.

</details>

## Reproduction steps

1. Run the desktop app and open a channel containing both agent and
human messages.
2. Compare agent avatars in the message list, inbox, huddle
participants, projects, and profile panel. Agent images and fallbacks
should share one smooth squircle silhouette at each size; human avatars
should remain circular.
3. Open **Agents** and inspect an empty agent-team card. Its placeholder
fill and outline should both be smooth squircles, with no straight inset
cropped-square edges.
4. Open an agent profile from a message and confirm the larger profile
avatar keeps the same proportions as the smaller message avatar.

## Testing

- Desktop Biome check passed for the changed empty-team component and
Agents E2E spec.
- Desktop typecheck and differential file-size check passed.
- Full desktop unit suite passed: 6,453 tests.
- Full Agents integration spec passed: 37/37, including the empty-team
outline regression.
- Post-rebase pre-push gates passed at `304b159a2`: desktop checks,
TypeScript typecheck, differential file-size gate, and all 6,453 desktop
unit tests.
- The shadcn-aligned `rounded-squircle` registration passed changed-file
Biome, desktop TypeScript/build, and pre-push desktop checks at
`27e0c12d3`; the full desktop unit suite passed 6,459/6,459.

## Screenshots

Screenshots are temporarily omitted. The previous captures used the
desktop E2E mock bridge and were not evidence from a live running app,
so they were removed.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-09-11 06:08:44 -10:00
Wes
f3408fc620 fix(desktop): isolate quota backoff and reuse channel discovery rosters (#6998)
Pinky, an AI agent, is updating this PR on Wes's behalf.

## Targeted repair, not a startup rewrite

This replaces the previous readiness/replay candidate. Current head:
**`83ac81ff6ae439b0e68f4a3dafd4b2af6dbadcdb`**, two independent commits
on main base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`:

1. **`0b6681de2` — keep quota backoff on its transport.** HTTP 429s
retain native HTTP backoff without arming the renderer WebSocket gate.
WebSocket quota/concurrency CLOSED retains its retry deadline without
damping HTTP's separate quota. The explicit `rate-limited: shared
admission unavailable` signal still damps HTTP too. Reuse existing
all-channel live streams for Home mention invalidation instead of a
second per-channel mention subscription; retain kind, recipient, self,
membership, replay-deduplication, notification and unread boundaries.
2. **`83ac81ff6` — reuse complete membership rosters within each channel
fetch.** Discovery already returns full paginated rosters. Retain those
results and request only uncovered directory/pending-owner channel IDs.
Skip empty fallback filters and preserve covered counts/pubkeys if that
tolerant fallback fails. The next fetch reads current membership again;
there is no persistent cache or new invalidation state.

No send-route, AUTH-readiness, replay-ordering, scheduler, pacing, relay
policy, dependency, or authorization changes. Refresh triggers and
activity batching stay unchanged. Independent tracing did not establish
a safe-to-delete duplicate normal-startup refresh; recovery
invalidations at different replay stages are not interchangeable.

**Size:** 18 files, +1,066/-350 overall; nine production paths +76/-205
(**129 fewer production lines**). The roster follow-up itself touches
only `fetch.rs` (+31/-14) and its new 378-line regression file. Test
bulk is not new startup machinery.

## Comparative roster measurement

Actual native HTTP fetch code against a synthetic Axum loopback fixture:
265 channels × 100 members, deterministic signed events, debug build.
Seven iterations per condition; medians below exclude the first warm-up.
Baseline is `0b6681de2` production fetch, candidate is `83ac81ff6`
production fetch, with the same temporary benchmark harness.

| Measure | Baseline | Candidate |
| --- | ---: | ---: |
| HTTP reads per fetch | 7 | 6 |
| JSON response-body bytes per fetch | 4,803,136 | 2,453,380 |
| Median, no artificial request delay | 262.9 ms | 149.3 ms |
| Median, 25 ms artificial delay per request | 376.7 ms | 282.7 ms |

Channel/member assertions and output hash (`fa7cc551bc855e4d`) match
throughout. This removes **one batched roster request**, not 265
requests. The measured ~43%/~25% reductions describe this large-roster
synthetic fixture, **not production startup latency**. Activity requests
run concurrently with roster enrichment, so real savings vary. Counts
now use the discovery snapshot rather than a later reread within that
same non-atomic fetch.

## Verification

At pushed head **`83ac81ff6`**:

- All enabled pre-push gates passed without bypass: `push-head-scope`,
`branch-skew`, `desktop-check`, `file-size-check`, `desktop-typecheck`,
`desktop-test`, `desktop-tauri-checks` (native fmt/Clippy/tests).
- Full Desktop frontend suite: **6,457 passed**, zero failures; separate
tooling suite: **10 passed**.
- Full native Desktop workspace: **3,276 passed**, zero failures, 20
ignored, across unit/integration/doc bins. The main native lib accounts
for 3,175 passed / 19 ignored; this is not 3,276 lib tests.
- `pnpm build` passed the protected-feature artifact matrix; `pnpm
build:e2e` also passed. Existing chunk-size/dynamic-import warnings
remain. No full repository `just ci` run locally.
- **130 selected headless mock-bridge smoke cases passed across two
runs**: messaging, reconnect, reconnect affordances, inbox live updates,
sidebar snapshots, send-channel binding, boot splash. The first
invocation hit the shell's 300-second limit after 117 passes and no
failures; all remaining 13 passed in a separate invocation. This is not
a claim the interrupted invocation completed.
- Six new tests invoke production `fetch_channels` through the real
native HTTP client and cover 265/501-channel pagination (including
same-timestamp continuation), exact requests, complete/deduplicated
rosters, directory/pending fallback, hidden DMs, repeated
membership/identity/relay changes, empty lists and errors.
- Falsification in an isolated source copy: restoring the redundant
baseline fetch fails four roster regressions; removing missing-roster
fallback fails two. Restored candidate passes all six. Earlier transport
mutations fail HTTP→WS coupling, WS→HTTP coupling, omitted shared-outage
damping and duplicate-mention regressions. No mutation was applied to
the committed worktree.
- Brain's independent static reviews found no blockers in the transport
repair or immutable roster patch. Source tracing confirms relay
discovery serializes complete rosters, not projected matching p-tags.
This was source review, not independent test execution or approval.

### Hosted CI

[CI run
34185670504](https://github.com/block/buzz/actions/runs/34185670504)
**completed successfully** for this head. GitHub checked out merge ref
`25bcf09627e129887481d4441a1af3c6cb0af1ff`; its Git tree is identical to
`83ac81ff6` (`49a01f3c2ebf4e547bbe3cd31406f984ac6cefe8`), verified
through the GitHub Git API. The final PR rollup has 33 successful checks
and 28 skipped, with none failing or pending.

- Desktop Core passed frontend/native suites and compiled-feature
verification. Windows Rust tests, Windows Desktop build, macOS
`.app`/`.dmg` build, all four smoke shards and both relay-backed E2E job
shards passed, as did Rust lint, DCO, Semgrep and zizmor.
- Browser job totals: **1,620 passed, 13 flaky cases passing automatic
retry #1, five skipped, zero terminal failures**. Smoke accounts for
1,389 passed / 12 flaky / three skipped; integration jobs report 231
passed / one flaky / two skipped. No manual CI reruns or source changes
to obtain green.
- The retries cover channel-browser, empty-edit, huddle menu,
runtime-install, overscroll, onboarding avatar, video attachment and
workflow UI assertions. They are disclosed, not collectively declared
unrelated. The video case reaches the changed real TS all-channel hook
despite mocking native I/O: independent tracing found no regression
mechanism or blocker, but the missing second-video player remains
**unexplained**, and an indirect timing effect is not excluded. The
self-authored, unmentioned event cannot use either the new mention
branch or the removed old mention handler as a delivery path. A passing
retry does not diagnose its cause.
- The separate “Desktop Release Candidate” check skipped its validation
step; it is not additional release validation. Codex security review was
skipped, not passed.

## Remaining acceptance limits

Wes's earlier manual trial of the transport-only candidate reported
improvement; relay readback found both a top-level message and a thread
reply. That was not a fingerprinted or timed trial of the new roster
commit. The remaining few-second top-level send wait is
**unattributed**, and this PR does not claim to resolve every startup
delay.

No visible app launch, relaunch, laptop input automation, or production
test messages during this follow-up. Local native tests use fixtures;
browser suites do not run the Tauri backend. CI building an app does not
validate bundled macOS alerts or inactive-channel notification delivery.
Those bundled real-workflow checks remain outstanding. No visual UI
changes or screenshots; no approval or merge performed.

Originating conversation:
buzz://message?channel=5b130804-d759-40ad-a564-d64cc907fa8e&id=cb432417e290e06a1842639cfe0268f9d43d186defbcb59260930d129a26af83

---------

Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
2026-09-11 09:11:39 -06:00
Taylor Ho
092c6a7277 fix(markdown): align mention chip wrapping (#7501)
**Category:** fix
**User Impact:** Human and agent mentions now break across lines with
the same cloned chip treatment as repository and permalink chips while
preserving the conversation text rhythm.

**Problem:** Profile-backed rendered mentions sat inside an
`inline-flex` popover trigger, unlike entity chips, so the wrapper
interfered with true inline fragmentation. The browser-layout test
measured text-range rows rather than the painted chip rectangles,
allowing touching decorations to pass as “separate” fragments.

**Solution:** Keep the profile trigger interactive but override its
layout to true `inline`, then give mention fragments 18px computed
leading inside the message’s 20px prose rhythm. Chromium paints each
fragment at 17px and advances it by 20px, leaving a visible gap between
cloned rounded rectangles. The browser test now measures the chip’s own
`getClientRects()` and asserts fragment count, height, gap, and step;
entity links retain their existing 22px leading.

<details>
<summary>File changes</summary>

**desktop/src/features/profile/ui/UserProfilePopover.tsx**
Allows inline consumers to override the trigger wrapper’s layout without
changing other profile-popover call sites.

**desktop/src/shared/styles/globals/markdown.css**
Keeps one shared wrapping-chip mechanic and gives mention decorations
enough room to separate visibly within 20px prose.

**desktop/src/shared/ui/markdown.test.mjs**
Pins both rendered mentions and entity links to the shared wrapping-chip
contract.

**desktop/src/shared/ui/markdown/MarkdownMention.tsx**
Makes the profile-popover trigger truly inline so the nested mention
chip can fragment with surrounding prose.

**desktop/src/shared/ui/mentionChip.ts**
Keeps `wrapping-inline-chip` as the single contract for fragmenting
decorated chips.

**desktop/tests/e2e/mentions.spec.ts**
Measures the painted chip rectangles, requires a positive fragment gap,
and verifies the inline trigger remains mouse- and keyboard-operable.

**desktop/tests/e2e/navigation.spec.ts**
Keeps a wrapped repository chip as the control, asserting its existing
22px line height and fragment advance.

</details>

## Reproduction steps

1. Open a channel in Buzz Desktop using dark theme.
2. Send a message containing a human mention and another containing an
agent mention; both chips should remain aligned with adjacent text on a
20px line.
3. Render a collision-qualified mention in a narrow message width; it
should break into separately decorated fragments exactly like another
wrapping chip, while each fragment follows the 20px prose rhythm.
4. Render a long repository or permalink chip in the same constrained
width; it should retain its roomier 22px fragment spacing.

## Screenshot

The dark-theme production renderer shows the real qualified label (`bob
(npub1hv3…tpuc)`) at an 8rem width. The two lines now paint as visibly
separate rounded fragments rather than one continuous rectangle.

![Qualified human mention wrapping into two visibly separate rounded
fragments in the dark-theme Buzz
timeline](https://github.com/user-attachments/assets/ee6e7be5-937e-4022-9c82-cf71f8203470)

## Validation

At commit `2b063e1b4ade30e11f1616269ad4ba4190366885`:

- Pre-push desktop gates — file-size check, Biome/checks, typecheck, and
6,483 unit tests passed
- `pnpm --dir desktop build` — passed
- Focused Playwright coverage for single-line agent mention, single-line
human mention, wrapped qualified mention including keyboard profile
activation, and timeline mention click — 4 passed
- `git diff --check` — passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <rizz@agents.buzz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-09-10 22:42:10 +00:00
cynfria
cec5c8fd92 fix(desktop): let inbox title and message author names truncate under narrow panes (#7550)
## Summary

Fixes two instances of the same dead-truncate pattern in the desktop
app, where a flex item's implicit `min-width: auto` prevented `truncate`
from engaging, so long text painted over adjacent controls instead of
ellipsizing:

- **Inbox detail title** (`InboxDetailPane.tsx`): the clickable
context-title button sized to its text instead of shrinking with the
pane, overlapping the header controls (open-in-channel, members, huddle,
more menu). Fixed by adding `max-w-full`.
- **Message author names** (`MessageHeader.tsx` /
`UserProfilePopover.tsx`): the `UserProfilePopover` inline-flex trigger
wrapper refused to shrink below the name's nowrap width, running long
author names under the hover action bar and off the pane edge. Fixed by
adding a `triggerClassName` prop to `UserProfilePopover` and passing
`min-w-0 max-w-full` at the author call site.

Two other suspected instances (project file breadcrumb, drafts pane
title) were stress-tested and already truncate correctly — no change.

### Related issue

N/A — none found.

### Testing

- New Playwright regression tests for both fixes
(`inbox-title-overlap.spec.ts`, `message-author-overlap.spec.ts`,
registered in the smoke project), each proven to discriminate: they fail
with the fix reverted (real measured overlap) and assert the ellipsis
actually engages with non-zero title width, so they can't pass
vacuously.
- Typecheck, lint, and full desktop unit suite green (pre-push hooks);
full desktop e2e smoke suite run earlier: 1402 passed, 3 pre-existing
unrelated failures (each fails identically with the fix reverted).

**Inbox title — before** (long title paints under the header controls):

![Inbox title before: title text overlaps the header control
icons](https://github.com/user-attachments/assets/d5a10f98-114f-4466-8012-468616b82807)

**Inbox title — after** (truncates with ellipsis, controls stay clear):

![Inbox title after: title truncates with an ellipsis before the
controls](https://github.com/user-attachments/assets/80520f7c-1120-4dbc-929d-1ef5eceb874b)

**Author name — before** (long name runs past the header row edge):

![Author name before: name glyphs bleed past the action
bar](https://github.com/user-attachments/assets/be9743ae-ef51-4db0-8c46-a0649e98f0d5)

**Author name — after** (clean cutoff):

![Author name after: name truncates cleanly inside the header
row](https://github.com/user-attachments/assets/24cc0412-ca99-40c4-add6-13380b3ae065)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: cynfria <yescynthia@gmail.com>
Signed-off-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tree Trunks <6ba22921d9dc2ad0aa6ecdf63787ddd24726e266d866da31af69f2e4e146ace5@buzz.block.builderlab.xyz>
2026-09-10 10:00:15 -07:00
Logan Johnson
ad2a84131e fix(desktop): npub identity displays for mention, member, and workflow surfaces (#7495)
🤖
## Summary

Every Buzz account is identified by a long public key. Before this
change, when someone had no display name, surfaces fell back to
inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`,
sometimes a generic role label with no key — so the same person looked
different from surface to surface, and nothing looked like an npub
address. This PR applies the npub identity foundation from #7488 to the
everyday surfaces: a person without a display name now falls back to the
same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable
spelling of their public key (first 8 + last 4 characters of the full
npub) — across messages and mentions, reactions, huddles, member and
participant lists, the sidebar and channel activity, search, projects,
tray, notifications, and workflow surfaces.

- **Mentions and messages**: key-only mention chips render the compact
npub. Pasting a copied mention back still re-binds it byte-exactly to
the identity it declares, for both the new npub chips and legacy
hex-truncated chips copied by older clients — wrong, missing, or
tampered key qualification is rejected instead of silently degrading to
plain text.
- **Reactions and huddles**: huddle reaction events and the huddle
roster/participants render the compact npub for unnamed participants;
workflow reaction triggers describe authors with the same form.
- **Members and sidebar**: channel and community member lists,
add-member results and invites, the members sidebar, the
channel-activity popover, search, projects (assignees/reviewers/PR
panels), the tray menu, and desktop notifications all fall back to the
compact npub; titles and aria labels keep the machine-readable full
labels.
- **Profile labels**: panel/popover display names and owner handles fall
back to the compact npub (never raw hex) when there is no name;
linked-event (nevent) message metadata shows the npub-shaped author
fallback while the event lookup and event IDs are unchanged.
- **Workflows**: author-picker secondary labels, step destination keys,
and trigger-author references render compact npubs; event and blob IDs
keep their existing hex compacts (they are not identities).
- **Avatars stay distinct**: fallback avatars for key-only identities
derive initials from the key's tail, so prefixed role labels like
"Participant npub1…" no longer collapse every unnamed participant onto
the same initials; people with names keep their name initials.

Preserved exactly: display names and distinct avatars, internal hex keys
(storage/API forms unchanged), clipboard identity roundtrips, event/blob
ID compaction, private keys (no nsec path is touched), and nevent link
handling.

Scope: this PR changes what identity labels **display**, not identity
controls — profile/settings copy controls, the respond-to allowlist,
workflow key fields, and agent dialogs are the sibling slice #7489, and
the shared primitives (`canonicalNpub`, `truncateNpub`, the `<PubKey>`
gate, strict input parsing) come from the foundation #7488.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for duplicates — none found;
the related work is the npub identity stack this slice belongs to.
- Base/dependency: stacks on #7488 (foundation) — this PR does not stand
alone on main.
- #7489 is a sibling slice on the same #7488 base
(profile/agent/workflow controls), not a dependency: this PR does not
require #7489, and #7489 does not require this PR — both only require
#7488.

### Testing

At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base:
`fix/desktop-npub-identity-d1a` @
`5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 —
production +277/−136, test support +379/−53):

- At this head: targeted `mentions.spec.ts` (1/1), the e2e build,
typecheck, and biome — green.
- 9 changed/related unit files: 100/100 green; typecheck, e2e build,
biome, and px text/truncation checks clean; huddle-roster focused run
green; channel-activity e2e 11/11; mutation checks confirm the fallback
wiring (removing it collapses shared initials and drops fallback rows).
- Known pre-existing local e2e failures, unchanged by this PR and
reproduced identically at the upstream merge-base: huddle-transcription
voice-menu attribution (25 pass / 1 fail) and the
`workflow-local-controls` 438px caret drift. Not claimed green locally.
- Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only
follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls`
races were fixed in the test drivers, and the 438px diff was shown to be
a stale Darwin snapshot baseline (name-row enable switch already absent
and `message_posted` already MessageSquare at recording commit
`9390e11c9`) and refreshed — the focused screenshot test, including
keyboard/caret assertions, now passes locally (twice). The full spec was
not rerun after the snapshot refresh; the huddle-transcription item
above is unchanged.

Label/copy text changes are asserted by the e2e specs (`mentions`,
`mention-recipients`, `pubkey-display-screenshots`,
`huddle-transcription`, `channel-activity-popover`,
`workflow-local-controls`) rather than new screenshots; the screenshot
spec pins the compact npub text forms.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:07:24 -04:00
Logan Johnson
bfc3848558 fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488)
🤖
## Summary

Identity keys in the desktop app are displayed as raw 64-character hex.
A person's key shows up as something like `953d3363…` — unreadable,
impossible to recognize as the same identity on another screen, and a
hazard when copied by hand. Nostr (the protocol Buzz runs on) has a
human-readable spelling for identity keys — the `npub1…` form — but the
desktop app did not use it consistently.

This is the foundation of the desktop npub changes: it adds the shared
pieces every identity surface builds on, and two follow-up slices stack
directly on this branch — #7489 converts the identity controls (profile,
settings, allowlist, workflow key fields) and #7495 converts the
everyday display surfaces (mentions, member lists, sidebar, and other
name fallbacks).

After this change:

- The shared identity widget shows the compact npub form —
`npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for
example the owned-agent public-key row on a profile). Copying it puts
the full npub on the clipboard.
- Copy is a real interaction, verified end-to-end: both popover variants
put the exact canonical npub on the actual clipboard — never the raw hex
the popover also lists, never a truncation — and a portaled popover's
clicks no longer steal focus from the new-DM To-field mid-copy. Pointer
copy, a natural Space-then-Enter path, and inner/outer Escape are
covered.
- Anything that isn't a valid identity key fails neutrally: short or
corrupt values — including degenerate values that technically encode to
a checksum-valid npub but aren't real identity keys — show "Unavailable"
with no copy button, instead of a misleading value.
- Both valid npub spellings display: all-lowercase `npub1…` and
all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing)
both render the same canonical lowercase npub. Mixed case is rejected by
the display path as written — `canonicalNpub` and the widget don't
case-normalize input — while input parsing (`parsePubkeyInput`) keeps
its trim-and-lowercase normalization and accepts mixed-case npubs; both
paths require the decoded payload to be exactly a 64-character identity
key.
- Identity-key input is strict on payload: an npub whose decoded payload
isn't exactly a 64-character identity key is rejected, matching the
validation the app's Rust side already applies to agent allowlists.

Intentional scope boundary: only surfaces that render through the shared
widget change here. Outer profile copy, settings identity cards, the
respond-to allowlist, and workflow key fields still show hex — they move
to npub in the controls follow-up (#7489). Nothing else changes identity
representation: display names, private keys, event IDs, and the hex the
app stores, sends, and matches internally are untouched; only the
user-facing spelling of an identity key changes.

## Details

- `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict
canonical full-npub helper (64-char hex in any case, or a
checksum-validated npub, returns the canonical npub; anything else
returns `null`); `truncateNpub()`: the compact display form; existing
exports unchanged.
- `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate
validates through `canonicalNpub`; the popover copies the npub only.
- `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects
npubs whose payload is not exactly a 64-character identity key.
- `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field
focuses its search input only for clicks that land inside the field
itself, so portaled recipient popovers keep their focus while open (a
popover click previously dismissed it mid-copy).
- Unit suites cover the helper, widget, and parser (including the
degenerate-encode and uppercase regressions); the e2e specs that render
these rows assert the npub display.

### Related issue

- Fixes: N/A. Searched existing issues/PRs for npub identity display —
no existing match.
- Stack: #7489 is based on this branch and builds on these primitives;
it does not stand alone on main.

### Testing

At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39):

- Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green;
mutation-checked — removing the decoded-length predicate fails the
short/empty checksum-valid-npub assertions in `canonicalNpub` and the
widget, and a wrong-identity clipboard value fails the new copy
assertions.
- `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite
6459/6459 at this exact head.
- Targeted e2e at this exact head: 8/8 across the two specs that own the
clipboard flows — `agent-access-warning.spec.ts` (compact variant,
agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full
variant, new-DM recipient verification: pointer copy, popover surviving
the copy, inner/outer Escape, Space-then-Enter).
- No Rust-side or build files change in this PR, so those results are
unaffected.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 10:32:26 -04:00
Taylor Ho
218633b8fd fix(link-preview): keep composer fetches user-paced (#7211)
**Category:** fix
**User Impact:** Link previews can keep loading while a message is being
composed, while sending still has a finite escape hatch and stalled
network transports cannot occupy preview slots forever.

**Problem:** Native metadata and image deadlines could collapse slow
previews into fallback cards while the user was still composing, and a
shared image-host cooldown made pasted batches fail inconsistently after
one rate limit. **Solution:** Keep preview resolution user-paced with no
aggregate request deadline, bound transport inactivity (15s DNS/connect,
30s idle read), serialize image requests by host, and allow at most one
server-directed cooldown wait of up to 30s across an image fetch and its
redirects. The existing bounded post-Send preparation and immediate Skip
paths remain unchanged.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/commands/link_preview.rs**
Removes aggregate native deadlines so composer metadata work can
complete at the user's pace, while retaining DNS/connect/idle-read
liveness bounds. Adds bounded host-paced image request coordination that
releases its gate during cooldown, waits inline at most once for at most
30 seconds, and cannot renew that wait through redirects or the outer
transient retry. Same-host image and favicon requests remain
deliberately serialized to align with host rate limits.

**desktop/src-tauri/src/commands/link_preview_rate_limit.rs**
Adds a fixed-size striped host gate so concurrent image requests are
serialized without retaining an unbounded attacker-controlled hostname
map.

**desktop/src-tauri/src/commands/link_preview_tests.rs**
Moves native link-preview tests into a dedicated module and covers the
user-paced metadata contract, bounded one-shot cooldown behavior, and
gate release while a rate-limited request sleeps—including a different
host sharing the same bounded gate stripe.

**desktop/src-tauri/src/commands/link_preview_youtube.rs**
Removes the thumbnail fetch deadline so YouTube previews follow the same
composer lifecycle contract while using the shared bounded transport.

**desktop/src/shared/lib/useResolvedLinkPreviews.ts**
Adds development-only metadata outcome diagnostics with elapsed time and
image/fallback state, without logging encoded image payloads.

</details>

### Reproduction steps

1. Open the desktop composer and paste several GitHub pull request links
whose OpenGraph images share a host.
2. Observe that image requests are paced by host instead of racing, and
slow-but-progressing preview work remains pending rather than
immediately becoming a completed favicon fallback.
3. Send while preview work is still pending and confirm **Preparing link
preview** remains bounded by the existing post-Send budget.
4. Use **Skip** during preparation and confirm the message proceeds
immediately.
5. In a development build, inspect the console for `[link-preview]
metadata fetch completed` diagnostics containing elapsed time and image
state without base64 payloads.

### Related issue

N/A — scoped from the linked Buzz implementation room.

### Testing

At current head `dfb394aafbee537e9ffb04ad3732d08f65f30b8e`:

- Production-bound paused-time metadata regression passed through
`fetch_link_preview_metadata`; restoring the former 10-second aggregate
wrapper makes it fail at the pending assertion.
- Native link-preview module: 19/19 passed.
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml` passed.
- Rust formatting and `git diff --check` passed.
- Pre-push `push-head-scope`, org safety, differential file-size,
branch-skew, and `desktop-tauri-checks` hooks passed.

At prior head `59e2dcf167b15c7a3e637ad2608008b7f9cef5f3`:

- Full Tauri Rust suite: 3,056 passed, 19 ignored; integration crates 7
+ 3 passed.
- Focused native link-preview suite: 26/26 passed.
- The pasted multi-preview workflow was exercised in the desktop app and
confirmed improved before draft publication.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@users.noreply.github.com>
2026-09-08 12:59:03 -07:00
Taylor Ho
f038cbbb0d fix(sidebar): simplify unread indicators and emphasize priority activity (#7134)
**Category:** fix
**User Impact:** The sidebar now presents unread activity consistently:
unread rooms are bold, offscreen activity is counted by destination, and
DMs or directed activity receive stronger emphasis.

**Problem:** Sidebar unread state was split across competing signals:
agent work could trigger overflow, message totals inflated its count,
non-DM rows showed redundant numerals, and routine room activity looked
as urgent as DMs or directed messages. This made the sidebar noisy and
made the overflow value harder to interpret.

**Solution:** Use one stable offscreen unread control that counts rooms
and DMs—not messages—and remove agent work from that signal. Keep
ordinary room activity quiet; promote DMs, mentions, broadcasts, and
relevant thread replies; preserve DM avatars and targeting; bold every
unread room; and retain thread dots while removing non-DM numerals.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/unread_catch_up.rs**
Classifies relevant thread replies as priority activity during native
unread catch-up so startup state matches live rendering.

**desktop/src/app/AppShell.tsx**
Passes the priority unread destination set into the sidebar.

**desktop/src/features/channels/useUnreadChannels.ts**
Projects unread destinations, priority state, and thread replies
consistently while keeping Dock badge behavior separate from sidebar
emphasis.


**desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs**
Removes tests for the superseded agent/activity overflow projection.

**desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts**
Removes the old agent-plus-message overflow projection so agent work
alone no longer creates the unread indicator.

**desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts**
Replaces activity-volume overflow state with unread-destination overflow
state.

**desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs**
Covers priority detection and destination-count labels for the new
overflow projection.

**desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts**
Counts distinct offscreen unread destinations and determines quiet
versus primary treatment per direction.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Renders one stable overflow control, preserves protected-DM visibility
filtering, and prioritizes visible unread DMs for previews and
navigation.

**desktop/src/features/sidebar/ui/AppSidebar.types.ts**
Adds the priority unread destination set to the sidebar contract.

**desktop/src/features/sidebar/ui/CustomChannelSection.tsx**
Stops forwarding non-DM unread counts into custom channel rows.

**desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs**
Updates control coverage for explicit emphasis, destination labels, and
DM targeting.

**desktop/src/features/sidebar/ui/MoreUnreadButton.tsx**
Applies quiet or primary treatment without changing geometry,
accessibility text, DM avatars, or click behavior.

**desktop/src/features/sidebar/ui/SidebarSection.tsx**
Removes non-DM row numerals while retaining unread weight and thread
preview affordances.

**desktop/src/shared/ui/UnreadPill.tsx**
Shares one composition between quiet and primary states so only color
treatment changes.

**desktop/tests/e2e/badge.spec.ts**
Covers destination counting, promotion without count inflation,
DM/thread priority, row bolding, and removed numerals; also captures the
reviewed UI states.

**desktop/tests/e2e/channel-activity-popover.spec.ts**
Updates channel activity assertions for the numeral-free row treatment.

**desktop/tests/e2e/channels.spec.ts**
Updates channel unread expectations to use bold text rather than a row
count.

**desktop/tests/e2e/thread-unread.spec.ts**
Keeps thread unread-dot and popover coverage while asserting the room
itself is bold.

</details>

## Reproduction steps

1. Open a workspace with enough sidebar destinations to scroll rooms
above or below the viewport.
2. Receive ordinary unread activity in an offscreen room; verify one
quiet `N unread` indicator appears and counts the room once regardless
of message volume.
3. Receive a mention, broadcast, or relevant thread reply in an
offscreen room; verify the same count becomes primary without changing
its geometry or adding a badge.
4. Receive an unread DM, including thread-only activity; verify the
indicator is primary, shows the DM avatar when available, and navigates
to that DM.
5. Scroll the destination onscreen or mark it read; verify the count and
treatment update from the remaining offscreen destinations.
6. Inspect unread non-DM rows; verify their names are bold, numeric
badges are absent, and thread dots still open the unread-thread preview.

## Screenshots

### Ordinary unread room
Routine offscreen room activity uses the quiet treatment.

![Quiet unread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-unread-overflow-default.png)

### Directed unread activity
The same destination count becomes primary when an offscreen room has
directed activity; geometry and label remain unchanged.

![Primary unread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-unread-overflow-primary.png)

### Thread-only unread DM
A DM stays primary and retains its avatar even when only its thread has
unread activity.

![Primary DM thread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-dm-thread-overflow-primary.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-09-04 23:03:41 +00:00
Logan Johnson
4afef8649a fix(desktop): restore mention chip identity icons (#7338)
## Summary

Restore the missing **@ glyph for people and robot icon for agents**
after #7133, and incorporate the requested compact public-key display.

- Wrapping mention chips render their existing bounded icon-bearing
leading fragment.
- Readonly chips show bound keys using the same `8 leading…4 trailing`
formatter as the channel member list: `Scout (150b20bd…15dc)`.
- Full literal labels and exact keys remain authoritative in metadata,
profile targets, title/accessible-name attributes, editor text, saved
bodies and recipient tags. Display abbreviations are never used for
recipient lookup.
- Copy/paste restores the full literal label for a complete compact
chip; partial selections remain plain text. Two keys sharing the same
abbreviation still round-trip to their separate exact recipients.
- No recipient-resolution, authorization, wire-format, composer, or CSS
changes. Existing labels, icons, cloud markers and ordinary mentions
remain intact.

## Verification

Published candidate `9365ab9bc9d9c5d10802580cd576f8e378ff492f`, based on
main `e09f715c9d0ee2cb7bf8a39061e601f3a502f588`:

- **6,444 desktop unit tests passed** on this candidate's final source
tree.
- **44 mock-Chromium tests passed, zero retries in the final run**
across mention recipients, clipboard and cloud provenance: exact
recipient selection, ambiguity rejection, send/edit/reopen, forwarding,
full/partial copy, mismatched-key rejection, matching-abbreviation
collisions and 100%/150% narrow-window geometry.
- TypeScript, desktop Biome/check guards, protected-feature production
build and E2E build passed.
- The compact-renderer regression fails with the formatting call
removed. Original missing-icon and hidden-text accessibility regressions
have red/green evidence.
- Fresh self-review traced rendering, full-key metadata, copy
classifier, paste normalization and identity trust. The same display
formatter owns the accepted compact form on both clipboard sides.

Iteration exposed an existing team-insertion separator flake (passed
final full run) and two new fixture assumptions: non-member sends
require invitation, and Chromium rich paste may retain an NBSP
separator. Tests now exercise invitation and normalize only that
separator when comparing the captured full body and exact tags; no
product change was needed for either.

Earlier local repository-wide `just ci` completed in two invocations
because its initial call hit the ten-minute tool limit during Tauri
compilation. Unchanged native/mobile/backend evidence is reused; the
desktop delta received the full checks above and new remote CI. Native
VoiceOver, real Tauri selection, dark theme and non-Chromium observation
were not performed. Browser artifacts exercise real frontend with mocked
Tauri/relay boundaries, not an installed release.

## Review and visual evidence

Current-head CI and automated review must complete after this update;
the old `2997bfb5` green results do not establish this new head.
Required human review remains separate from agent approvals. No
merge/install/restart authorization.

Before/after icon evidence:
https://github.com/block/buzz/pull/7338#issuecomment-5544575866

Updated compact-key screenshots are posted below. The editor
intentionally retains the full literal address; only readonly chip
display is abbreviated.

Origin:
buzz://message?channel=3355d33a-b72a-423a-b064-a58275f9a8af&id=38b3a27e689f5a9604e273d45f4e3122fceba76081e7bcd3bbdbf439524a5a18

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-04 13:36:17 -07:00
morgmart
5d107836c6 Persist video playback speed preference (#7336)
## Summary

- persist the selected desktop video playback speed as a device-level
preference
- apply the shared preference to inline and review video players,
including after a reload
- cover persistence, validation, cross-window updates, and the
end-to-end playback flow

## Verification

- `node --test
desktop/src/shared/lib/videoPlaybackSpeedPreference.test.mjs`
- `cd desktop && pnpm exec tsc --noEmit`
- `cd desktop && pnpm check:px-text`
- `cd desktop && pnpm test`
- `cd desktop && pnpm build:e2e && pnpm exec playwright test
--project=smoke video-attachment.spec.ts`
- pre-push: desktop check, file-size check, typecheck, and 6,416 desktop
tests

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-09-04 13:10:17 -07:00
Logan Johnson
ee883d73fb fix(desktop): bind duplicate mention selections to exact recipients (#7133)
## Summary

Selecting two people or agents named Scout could replace the first
recipient with the second even though the message still looked right.
This binds each selection to its exact identity: the first keeps
`@Scout`, and a conflicting selection gets `@Scout (<full public key>)`.
Removing one no longer removes or redirects the other.

- Reuse the existing **@ suggestion list**; team selection and automatic
agent addressing reserve and reuse distinct labels too. Typing an
ambiguous name manually shows an instruction to use the picker and
preserves the draft without publishing—in chat, edits and standalone
forums.
- Share literal mention matching across recipient extraction, removal,
display and editing so a shorter name cannot claim another recipient's
longer or qualified label.
- Rebuild authored references when a message is edited, and use the
latest authorized snapshot when reopening or forwarding.
Rendering/editing can recover qualified identities only from the
message's recorded references, not from a key typed into its body.
Unresolvable historical names remain literal rather than guessed
recipients.
- Wrap full-key labels within narrow/zoomed layouts while keeping their
complete accessible label and ordinary mention icons. Edit activation
waits for the action menu's focus cleanup before focusing the editor.

### Related issue

Targets `main`; mention spacing (#7128) is already merged. Split from
#7114. This is independent of the #7124 → #7125 remote-invitation stack
and does not expand agent eligibility or invitation permissions. The
separate Enter-selection suffix issue remains tracked in #7253.

### Testing

The desktop unit suite and focused mock-Chromium checks passed on the
published integration candidate, including pending-paste selection,
edit/forwarding, copy and narrow-layout cases; formatting, types and
frontend builds passed. On `0b3b18c0`, 64 focused trust/paste/selection
unit tests and six mock-Chromium tests passed with zero browser retries,
including actual timeline chip copy → fresh channel paste → send and
mismatched-key rejection; TypeScript, changed-file Biome and an isolated
E2E build passed. The broader browser run had copy failures before the
focused repairs and is not claimed as wholly green. See [live
CI](https://github.com/block/buzz/pull/7133/checks) for current-head
results. No full local `just ci` pass, native/live-relay or
cross-browser validation is claimed.

To try it: select two same-name recipients, remove one, send, then
edit/reopen and forward; only the intended identities should remain.
Type an ambiguous name without choosing a suggestion and check that
sending retains the draft with an error. Inspect full-key labels in a
narrow window at 150% text size, and open Edit and type immediately.

![Ambiguous mention keeps the chat
draft](https://raw.githubusercontent.com/block/buzz/c48053209b36b3b1bf7197cf2d606d27b48a27c0/pr-7133--ambiguous-general.png)

![Full-key label in a narrow, zoomed
composer](https://raw.githubusercontent.com/block/buzz/ac262f00de6fe3860bff2f87dc0e98165ed6b651/pr-7133--layout-1.5-composer.png)

*Earlier mock-browser captures, not current-head runtime proof. No new
before-state capture; screenshots alone do not prove recipient
delivery.*

**Clipboard trust:** generated full-key-qualified mentions now retain
their exact recipient after copy/paste when the full key matches the
clipboard record and community directory/profile state independently
vouches for the base alias, including numeric collision suffixes. A
qualifier alone does not establish trust. Arbitrary historical labels
still cannot always be reconstructed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Bad Janet <150b20bdf6130418df9239dd1bd082c71612c8d653b47c277200365b9be215dc@buzz>
2026-09-04 11:51:41 -04:00
Matt Toohey
b1f6b7ef77 feat(desktop): preserve mentions across copy and paste (#7228)
Copying a message out of the timeline lost the mention. The rendered
chip drops
the `@` for display, so the clipboard carried "John Smith" — two
ordinary words
no composer could bind back to a pubkey. Pasting into another channel
produced
dead text, and sending it tagged nobody.

## What changed

Every Buzz copy now writes two clipboard flavors in one transaction:

- **`text/plain`** — readable anywhere, sigils restored, no pubkeys.
This is what
  TextEdit, Slack, and every other external app receive.
- **`text/html`** — the same content with each mention wrapped in a span
carrying
  `data-mention-pubkey` / `-label` / `-kind`.

On paste, the composer harvests those records, registers each `name →
pubkey`
with the existing mention machinery, and inserts the content: the chip
re-lights
and the send path recovers the identity the author tagged. A marker
attribute
records what the plain flavor holds, so a Markdown copy pastes through
the text
pipeline and a rendered copy through the HTML one.

**Covered surfaces:** timeline selection copy, thread-panel selection
copy,
forum post/reply selection copy, "Copy message", and composer copy/cut —
plus
paste in both the channel and forum composers.

## Trust boundary

Clipboard HTML is untrusted, and the branch treats it that way:

- Records are capped (50), labels bounded (200 chars), and a pubkey must
be
  64 hex before it can become a `p` tag.
- **Only mentions the paste actually shows are registered.** An empty
`<span data-mention-pubkey=… -label="Jane Doe">` would otherwise rebind
that
display name for the rest of the composer session, so a later
hand-written
@Jane Doe would chip-light convincingly against the attacker's key. Each
  branch registers only the records whose label appears in the text *it*
inserts, matched with `getMentionOffsets` — the same matcher the
send-time
  extractor uses.
- **The visibility gate reads only what ProseMirror will insert.**
`DOMParser` hard-drops `script`, `style`, `title`, `noscript`, `object`,
and
`head` content, so `visible<style>@Jane Doe</style>` beside an empty
chip span
used to smuggle a binding past the gate. Those elements are stripped
before
  either output is derived.
- **A partial chip never gains a sigil.** A selection crossing a chip
boundary
falls through to the browser's default copy, which serializes the full
identity
attributes around a slice of the text — pasting that invented "@Smith"
out of
"John Smith". Paste now leaves a fragment as plain text, tolerating only
what a
whole chip picks up in transit (restored sigil, author casing, U+00A0
swaps,
  and the label cap's own ellipsis).

Both clipboard sides share one `matchChipTextToLabel` helper, in the
module that
owns the label attributes, so copy and paste cannot drift on what counts
as a
whole chip.

## Notes

- Mention matching reuses `getMentionOffsets`, so code spans and fences
are
  excluded and the longest display name wins.
- The plain flavor inlines chip boxes before reading `innerText`; a chip
is a
flex container, so the browser's own copy split "@John Smith" onto its
own line.
- `MarkdownMention` and `MacEmacsTextShortcuts` are extracted verbatim
from
`markdown.tsx` and `useRichTextEditor.ts` to keep both files under the
size gate.

## Testing

- ~40 unit tests over the flavor builder/parser, the visibility filter,
the
  ignored-tag sweep, and the chip-match verdicts.
- A Playwright spec (`desktop/tests/e2e/mention-clipboard.spec.ts`)
driving real
  copy/cut/paste DOM events: timeline and "Copy message" of a multi-word
non-member mention pasted into another channel and sent with the
original
pubkey in its `p` tag; the forum copy → forum reply round trip; composer
  copy/cut; plain flavor asserted to contain no 64-hex string; the
boundary-crossing drag; and the hidden-record and `<style>` smuggling
vectors.
- Every regression test is bound to a production seam and fails with its
guard
  removed.
- `just ci` / pre-push lanes green (5927 desktop unit tests, typecheck,
lint,
  file-size gate).

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 16:45:13 +10:00
Logan Johnson
6cf514ed90 fix(desktop): wrap message tables within the available pane (#7279)
## Summary
- Let Markdown tables use the available message width instead of their
maximum-content width, inheriting the renderer's existing
`wrap-anywhere` handling for long tokens and links.
- Top-align header cells as well as body cells. Give cells a `min-w-24`
readability floor so short labels do not collapse into one-letter
columns; retain the existing table-local scrollbar when many columns
genuinely cannot fit.
- Preserve semantic table markup, links, inline code, and surrounding
message layout. Add three browser regressions through the real message
renderer (channel, narrow thread, and many-column overflow).

### Related issue
Fixes #5313. No matching open table-readability PR found.

Owner-authorized task channel: `819b36d5-7371-4ed0-bf9b-d461d723779e`

Source:
buzz://message?channel=819b36d5-7371-4ed0-bf9b-d461d723779e&id=3bf2d1bd5df28fcb481e71159dc1be2b3888918b05f67e270354fbfea65a8c90

### Testing
Original production candidate:
`4427f2e6f9ef85a01a267fe9eabd1cf13cacfb78`; base:
`7a9a5233d9d755e715be0c585cf7850e935d28cf`.

- `pnpm -C desktop check` — passed.
- `pnpm -C desktop test` — 6,110 passed, none skipped.
- `pnpm -C desktop typecheck` — passed.
- `BUZZ_PROTECTED_BUILD_OUTPUT=<isolated-production-directory> pnpm -C
desktop build` — passed.
- `pnpm -C desktop build:e2e --outDir <isolated-candidate-directory>` —
passed.
- `just file-size-check` and `git diff --check` — passed.
- Chromium / mock Tauri bridge: all 3 new Playwright tests passed
against the fixed candidate build. Both wrapping tests fail on the
unchanged base build, while the overflow-fallback test passes. Each
browser run used a unique output directory and a non-reused local server
pinned to its build directory.
- At 883px channel message width: table scroll width **1,395 → 883px**.
At 292px thread message width: **1,395 → 292px**. All cells top-aligned;
long tokens/URLs wrap, link destinations and code text remain intact,
short labels stay on one line, and document width remains 1,280px.
- Focused fresh-frame review checked actual before/after screenshots and
the complete diff. It caught one-letter label wrapping in an early
width-only candidate; the final cell-width floor and regression
assertion address that finding.

Limitations: native Tauri/WebKit and relay-backed integration were not
exercised for this CSS-only delta. Repository-wide `just ci` was
attempted but exceeded the local command budget during unrelated Rust
compilation; it is **not** reported green. The full affected TypeScript
package gates above passed on the exact candidate. Production/E2E builds
retain existing chunk-size and mixed static/dynamic import warnings.

Before/after screenshots are posted below using the repository's
screenshot publication script. No merge, production installation, or
runtime restart is requested.


### CI-driven test-only follow-up
Current head: `925d964b6cf31ba704d74baf73769110774b2789`.

Smoke shard 3 exposed an older test in `messaging.spec.ts:457` that
still required three columns of ordinary prose to overflow horizontally
([failure
log](https://github.com/block/buzz/actions/runs/33771325404/job/100702242706)).
This expectation contradicts the intended wrapping change. Updated its
name and assertion to require containment; the separate many-column
local-scroll test is unchanged. No production code or harness settings
changed in this follow-up.

- Affected browser validation: **4/4 passed** (updated existing
prose/narrow table case plus the three new channel/thread/overflow
cases).
- Reused the immutable `4427f2e6` E2E build on a fresh non-reused
server; production source is unchanged at the current head. The
package/build checks above remain evidence for that unchanged production
tree, not a claim of rerunning the full suite at the new SHA.
- Targeted Biome check, file-size gate, and `git diff --check` passed.
- Current-head CI:
https://github.com/block/buzz/actions/runs/33773490446
- Prior unrelated Bestie baseline failure and reproduction:
https://github.com/block/buzz/pull/7279#issuecomment-5528077181
- Security authorization and review must target the **current** head,
not the old screenshot/build SHA. A Block organization member must
comment exactly `@buzz-security-review
925d964b6cf31ba704d74baf73769110774b2789`.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-03 16:07:14 +00:00
klopez4212
c6ca9d94d9 Show status and huddle indicators beside names (#7112)
## Summary

- show status and huddle emoji beside names in DMs and message rows
- provide accessible tooltips, fallback status emoji, and profile-menu
icon replacement
- add the desktop status editor with preset durations, a ShadCN
calendar, and a capped half-hour time menu

## Validation

- desktop checks, typecheck, and file-size guard
- 5,802 desktop tests
- focused Playwright coverage (3 passed)
- E2E build and native Builderlab staging verification

Updated visual snapshots are attached in the PR comments.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-09-03 09:39:37 +00:00
Matt Toohey
40220d5618 perf(desktop): publish mention sends before waking agents (#7154)
## Summary

Cuts perceived agent-mention send latency by publishing the message
first and waking the agent afterwards, instead of blocking the send on a
synchronous agent start/deploy round-trip. A send that mentions a
stopped or undeployed managed agent now shows the message immediately;
the wake runs fire-and-forget after the relay accepts the publish. The
already-running-agent send also gets faster via revalidation dedupe and
NIP-11 caching.

## Changes

### Publish-first agent wake

- Wakes for mentioned managed agents are collected during send
preparation and flushed fire-and-forget only after `await send(...)`
resolves. No start can fire — and no "your message was sent" toast can
appear — for a message the relay never accepted; every abort path
(cancel, readiness error, publish rejection, dismissed non-member
prompt) simply drops the queue. Persona-create wakes ride the pending
draft behind the non-member prompt for the same reason.
- Each wake is bound to the tenant scope captured at send time: the new
`useDetachedAgentStart` hook passes `expectedRelayUrl` +
`expectedSignerPubkey` with every start, so a wake that outlives a
community switch fails closed at the backend instead of spawning against
the new tenant. A wake whose scope has not resolved yet (identity query
still loading, blank stored relay URL) is refused with a recoverable
toast rather than fired unscoped.
- In-flight wakes are deduped through a module-level map keyed by
`(relay URL, pubkey)` — the same tenant pair the backend keys on — so
two quick sends or two composers cannot double-spawn a cold agent during
the seconds-long start window. Entries are deliberately retained across
community switches (the key *is* the tenant scope, so a retained entry
can never affect another community, and clearing it let an A→B→A round
trip deploy a provider agent twice) and self-clean when the start
settles.
- Wake-failure toasts are fenced to the community they fired in via a
module-level scope mirror: a start that settles after a community switch
logs instead of rendering community A's failure over community B's UI,
and an A→B→A return re-delivers the warning where it is actionable.
- Membership attach and access-policy writes stay synchronous, so the
harness's first kind-39002 read still sees the channel.

### Replay floor

- The send timestamp travels with the wake as `BUZZ_ACP_REPLAY_FLOOR`,
threaded through both local spawns (`spawn_agent_child`) and provider
deploys (`deploy_to_provider` injects it into `launch.policy_env`), so
the harness's startup watermark replays back past the just-published
triggering message no matter how long the spawn takes. `buzz-acp` clamps
the floor to `[now − 15min, now]`.
- The floor is captured at enqueue time, not flush time — the flush runs
post-publish, so a flush-time stamp could exceed the message's
`created_at` and skip the very message the floor exists to cover.
- On local spawns the caller's floor is asserted *after* the user env
layering (and the ambient parent-process value is stripped
unconditionally), so a saved persona/global/agent env entry cannot
shadow this send's floor — mirroring the shadow-strip the provider path
applies to `launch.env`. Both halves share one `REPLAY_FLOOR_ENV_VAR`
const.

### Send-path latency reductions (already-running agents)

- Mention revalidation is deduped: the publish-boundary pass reuses the
pre-side-effect authorization pass unless an awaited round-trip actually
separated the two (background upload, link-preview settlement, DM
expansion, a real access-policy/membership write, or active-huddle
enrollment). This preserves the #5681 authorization boundary while
making the common send single-pass.
- NIP-11 `self` lookups are cached per relay URL for 5 minutes. Only
verified values are cached — non-2xx and malformed responses stay
retryable — and URL keying keeps community switches from serving another
relay's identity.
- `applyReusableAgentAccessPolicy` now reports its relay write
explicitly (`{ agent, wrote }`) instead of signalling through object
identity, so the revalidation trigger above is load-bearing by
construction.

### File splits

Four files crossed the repository file-size ratchet during this work;
one cohesive unit was extracted from each rather than raising a ceiling
— `runtime/setup_payload.rs`, `commands/agents_create_fields.rs`,
`app_state_accessors.rs`, and `useEnsureAgentMentionsReady.ts`. The
ratchet is green at the tip.

### Review follow-ups

The three concrete findings from the first review round are fixed at the
tip: the pre-publish wake and its false "your message was sent" toast
(fixed by queueing wakes behind the publish), the stale cross-community
failure toast (fixed by the scope-mirror fence), and the A→B→A duplicate
provider deploy (fixed by retaining the tenant-keyed in-flight entries
across switches). The fast-path admission-staleness point is answered in
the review thread: deferred paths already re-validate at the publish
boundary, and the remaining fast-path window is milliseconds against an
irreducible network-transit race.

Mid-branch send-perf instrumentation was added to attribute the residual
spinner latency and reverted once that analysis concluded — it is
net-zero in this diff.

### Deferred follow-ups

Durable mention catch-up via `event_mentions` (option 2 step 3) and
backend deploy-epoch coalescing for the wake paths that do not funnel
through `useDetachedAgentStart` (Agents-panel Start, restore,
inbound-persona deploys) are intentionally left for separate changes.

## Testing

- `cargo test --lib` on desktop/src-tauri: 3054 passed; clippy `-D
warnings` + fmt clean
- Desktop unit tests: 5856 passed (the 5 failures are the pre-existing
`inboxReopenNavigation` / `useRetainedProjectGitViews` baseline, present
on origin/main); `tsc --noEmit` and biome clean
- Full mentions (87), channels (89), and community-rail (25) Playwright
smoke suites against `pnpm build:e2e` bundles, with 3× stress reruns of
each new spec
- The load-bearing regression specs were confirmed red on the pre-fix
code: publish-failure → zero starts and no false toast, the dedupe hold
(1 call vs 2), the fail-closed scope refusal, the rail-switch toast
fence, and the A→B→A retention spec (1 deploy vs 2)
- New unit coverage pins the queue contract (enqueue-time floors,
attach-seam queueing), the scope capture and verbatim relay-URL handoff,
the dedupe map's keying and settle-then-repermit behavior, the unscoped
refusal, the toast-scope mirror, the `{ agent, wrote }` contract, and
the replay-floor env layering on both spawn paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 14:46:11 +10:00
Logan Johnson
5073e075d7 fix(desktop): unify owned-agent cloud provenance markers (#7129)
Unify presentation-only cloud provenance across agent identity surfaces. Keep successful local-inventory and verified-ownership gates; preserve main availability and mention spacing behavior.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-03 02:35:37 +00:00
Arjun Mahanti
ac5a18697c feat(desktop): add persistent Bestie experience (#7223)
## Summary

- add a persistent, community-scoped Bestie designation for local
managed agents
- surface the designated agent in the sidebar, agent library, profile
actions, message toolbar, and draggable floating shortcut
- bloom the floating shortcut into a lightweight compact composer that
reuses the normal DM timeline, reactions, presence, and send behavior
- support message handoff with a bounded snapshot and a full Buzz thread
link so the agent can retrieve the complete conversation

## UX details

- the floating avatar and expanded panel stay above app chrome and drag
as one aligned surface
- closing the expanded panel returns it to its top-right anchor
- each mini-composer opening starts visually fresh while messages sent
during that opening remain conversational
- the designated Bestie's duplicate DM entry is hidden from the regular
DM list
- Bestie actions are suppressed inside the mini timeline to avoid
recursive handoff

## Reliability and maintainability

- preserve existing retention database paths across upgrades
- serialize assignment and deletion, clearing matching assignments
across community scopes before an agent is removed
- fence async conversation resolution against workspace and assignment
changes
- validate stale assignments against existing local agents before hiding
DMs
- share lightweight assignment state across agent cards and keep
protected-feature behavior out of the shared timeline API

## Testing

- `pnpm --dir desktop test` — 5,886 tests passed
- `pnpm --dir desktop exec tsc --noEmit`
- `pnpm --dir desktop exec biome check ...`
- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml
--all-targets -- -D warnings`
- focused native retention and Bestie assignment/command tests — 34
passed
- `VITE_BUZZ_BESTIE=1 pnpm --dir desktop build:e2e`
- `VITE_BUZZ_BESTIE=1 pnpm --dir desktop exec playwright test
--project=smoke tests/e2e/bestie.spec.ts`
- pre-commit and differential pre-push hooks

## Rollout

The UI remains gated by the `bestie` build feature. Screenshots covering
setup, empty, assigned, floating, and message-handoff states are
included in the PR discussion.

---------

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-02 17:56:17 -04:00
Will Pfleger
6f6093243e fix(desktop): harden profile batch and thread-reply fetches against relay slowness (#7188)
Three client gaps turn transient relay failures into permanent UI
degradation. Under a slow or rate-limited relay:

1. A cold channel's profile batch exhausts its single retry and leaves
raw npubs + broken mention chips until the user manually kicks the
channel.
2. A thread opened from a notification trusts a successful-but-empty
reply read as authoritative and never retries.
3. A rate-limited `CLOSED` on a history subscription immediately rejects
the caller rather than retrying after the rate-limit window.

All three are addressed without changing global query defaults or the
happy-path behavior.

## Changes

**Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`,
`desktop/src/features/profile/hooks.ts`)

Override `retry: 3` with exponential backoff and error-gated
`refetchOnWindowFocus: (query) => query.state.status === "error"`,
scoped to this query only. The global defaults (`retry: 1`,
`refetchOnWindowFocus: false`) are intentional for other queries and are
unchanged. After the retry budget exhausts, a window-focus event (e.g.
channel-switch) recovers the query automatically — but only when it is
already in an error state, preventing unnecessary refetches for
successful batches.

**Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`,
`ChannelScreen.tsx`)

Add optional `expectedEventId` parameter. When a completed paged fetch
does not contain the expected event, throw
`ThreadExpectedEventMissingError` so React Query's built-in retry
machinery handles it rather than caching an authoritative empty.
`ChannelScreen` passes `threadScrollTargetId` (the notification-linked
reply ID) as `expectedEventId`.

When notification routing changes `expectedEventId` while the same
thread root is already mounted (same query key), an explicit
`invalidateQueries` in a `useEffect` triggers a fresh validation pass.
The `useEffect` is declared after `useQuery` so TanStack's internal
options-update effect installs the new `queryFn` closure first; the
refetch therefore uses the current `expectedEventId` rather than the
previous null closure. For the cold-start race (target arrives before
the first page returns), the effect detects `fetchStatus === "fetching"
&& status === "pending"` and calls
`cancelQueries().then(invalidateQueries)` so the obsolete in-flight
response cannot settle as authoritative before the new target's
validation closure is active.

The query-fn tracks consecutive fetch attempts per target. On attempt 3,
it adds the target to `exhaustedTargetsRef` before calling
`loadThreadReplies`. `loadThreadReplies` sees the target in the
exhausted set and returns the fetched replies directly rather than
throwing — the terminal attempt always resolves to success. No
re-entrant scheduling: the resolution is synchronous inside the query
function itself. Deleted/moderated targets never lock the thread in a
terminal error surface.

**Fix 3 — CLOSED recovery for history subscriptions**
(`relayClosedRecovery.ts`, `relayClientSession.ts`,
`relayClientShared.ts`, `relayGateBoundary.ts`)

On a rate-limited `CLOSED` the subscription previously rejected the
caller immediately. Store `filter` and `timeoutMs` on
`HistorySubscription`, then on rate-limited `CLOSED` re-register under a
fresh `subId` and defer `sendReq` until the rate-limit window clears —
matching the live-sub recovery design already present in
`relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries
reject immediately so callers are never left waiting indefinitely. A new
op-timeout guards the retry REQ against a non-responding relay; when the
op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the
behavior of the original timeout path) so the relay releases the slot
rather than counting it against the per-connection cap.

## Tests

- `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for
history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send +
late-EOSE non-regression, rejecting-`closeSubscription` swallowed
without unhandled rejection, wiring source assertion (fails if
`relayClientSession.ts` drops the `closeSubscription` callback) — 18
tests
- `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests
(throw/exhaustion-guard); behavioral hook tests via real
`QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data,
settled null→target change retries on missing-target page and lands
target data (fails if `invalidateQueries` is removed OR if `useQuery` is
moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated
fetcher — released after rerender, stale empty discarded, replacement
fetch settles with target); ChannelScreen wiring source assertion — 9
tests
- `profileBatchResilience.test.mjs`: source assertions for `retry: 3`,
`retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global
defaults — 2 tests

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-02 17:42:45 -04:00
Taylor Ho
b6dc533b7c fix(composer): align wrapped inline chip fragments (#7242)
**Category:** fix
**User Impact:** Wrapped channel and mention chips in the chat composer
now align continuation text with the chip edge while keeping the icon on
the first line.

**Problem:** Plain composer decorations used absolute icons plus cloned
icon-sized padding, so every wrapped fragment inherited an empty icon
gap and the icon aligned against the union of all lines. **Solution:**
Keep the icon in the first fragment's inline flow and restore normal
chip padding on continuation fragments, while explicitly leaving the
separate wrapping Buzz-link and sent-message rendering paths unchanged.

<details>
<summary>File changes</summary>

**desktop/src/shared/styles/globals/composer.css**
Scopes in-flow icon geometry and normal continuation padding to plain
composer mention and channel decorations, excluding wrapping atom-link
chips and preserving the human-icon vertical correction.

**desktop/tests/e2e/mentions.spec.ts**
Adds a rendered narrow-composer regression that checks two fragments,
static icon geometry, first-line icon space, and continuation-line
alignment to ordinary chip padding.

</details>

## Reproduction steps

1. Open a channel in Buzz Desktop.
2. Narrow the chat composer enough for `#all-replies` to wrap.
3. Confirm the channel icon occupies only the first line and `replies`
starts at the chip's normal left padding rather than an icon-sized
inset.
4. Send or view a long inline Buzz chip in the message list at a
constrained width.
5. Confirm its icon remains attached to the leading fragment and its
remaining label continues cleanly on following lines.

## Screenshots

**Composer — wrapped `#all-replies` channel reference**

![Wrapped channel reference in the narrow dark-theme
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7242/composer-wrapped-channel.png)

**Message list — existing wrapped inline-chip rendering preserved**

![Wrapped repository chip in a sent message at constrained
width](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7242/message-list-wrapped-chip.png)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-09-02 14:08:12 -07:00
Logan Johnson
434dafe958 fix(desktop): discover authenticated owned relay agents (#7122)
🤖

## Summary

An agent you own could be missing from **New message → To:** and
**Channel members → Add people and agents** on a machine that has never
managed it. This PR lets those existing lists find your agent without
requiring a shared channel first. Desktop now checks records proving you
own it, rather than looking only at agents in channels you've already
joined.

**No new screen or control is added.** For example, an agent with
verified ownership and **Who can send instructions → Only me (default)**
can now appear even with no shared channels. Each screen still applies
its existing access rules; this does not make every discovered agent
selectable everywhere.

| Screen / control | Before | After this PR alone |
| --- | --- | --- |
| **New message → To:** recipient picker | An owned agent absent from
this machine and shared-channel bot lists could be missing. | Its named
**agent** row can appear; selecting it adds a recipient chip. This is
recipient selection, not a guarantee that a later message will reach or
wake the agent. |
| **Channel members → Add people and agents** | The same agent could be
missing from **Not in this channel** search results. | Its row can
appear with the existing **Add** button. If you can add members, that
button submits the existing channel-membership request; finding the row
alone changes no membership. |
| **Stream / forum composer → @ suggestions** | An owned agent already
in the channel under an ordinary member role could be missing from agent
suggestions. | Its actual membership is recognized without requiring the
bot role. Agents not managed on this device still need membership in
that channel. |
| **Pulse → Agents** | An agent absent from both local management and
the server's agent list was omitted from the count and author lookup. |
The count and feed's author lookup can include it; notes appear only if
it has published them. |

Being listed does **not** mean the agent is online, add it to a channel,
or grant local Start/Edit controls. For agents not managed on this
device, global **Search** still excludes those configured for “Only me”,
and DM @ selection is not added here. DM @ selection and message-driven
nonmember invitation are addressed in
[#7124](https://github.com/block/buzz/pull/7124); the standalone forum
**Invite / Cancel** flow is in
[#7125](https://github.com/block/buzz/pull/7125).

<details>
<summary>Ownership and membership checks</summary>

A discovery lead is not proof: the latest agent profile must have a
valid signature and exactly one valid ownership attestation—the owner's
signed link to that agent. Its response policy must be signed by that
verified owner; an invalid latest policy cannot restore an older
permission. Membership comes separately from the latest server-signed
roster, including removals.

Existing profile cards, owner labels and agent-avatar shapes also use
this stricter verification: malformed or forged evidence must not supply
ownership/agent classification on its own. Valid ownership was already
recognized; no profile-picture or badge design changes.

Attestation time conditions apply to the signed event's timestamp, not a
live expiry timer. Existing legacy compatibility and builds requiring
verified owner policy retain their respective rules. Discovery and
sending remain separate operations, not an atomic permission check.

</details>

### Review corrections

- When runtime and owner policy overlap, **explicit online/away/offline
from the verified latest runtime is retained**. Policy still supplies
ownership/permissions; claimed runtime membership is not restored.
Missing/unrecognized status stays unknown, and invalid latest policy
cannot revive runtime permissions.
- Discovery without runtime evidence is now **unknown**, not offline:
native conversion, both IPC adapters, Pulse, Projects and
profile/session consumers preserve that distinction. Unknown has no
status dot and is not promoted to a deployed/running agent.
- Both relay-only picker paths retain the authenticated owner, including
the existing **managed by you** label. The analogous global Search
projection is fixed without changing its existing “anyone” filter.
- Authorized stored profile activity remains visible when liveness
becomes unknown/absent or the active turn ends. History reads do not
start a live subscription, grant access, or imply current availability.

### Related issue

Independent base: `main`. Child:
[#7124](https://github.com/block/buzz/pull/7124), then
[#7125](https://github.com/block/buzz/pull/7125). Extracted from
[#7114](https://github.com/block/buzz/pull/7114), retained as historical
source (`98fe33ec`).

[Behavior
contract](3a56d17824/docs/owned-agent-discovery.md).
Originating [Buzz
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848)
· channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`.

### Testing

Current candidate: `3a56d17824522580fe04cae463b54f4c7ba66021`, a
four-file native/test/doc runtime-status repair atop published
`ae23c1c9680a881cee7eed94e259bf15bf8ce3f7`. Branch ancestry is main
`1c8321cd08feb597f8bcff5195c21148fb3e98ed`; refreshed main
`0e878664b08cdf7fb2d89d940bc2aa92cdc485f7` adds only the independent
CI-workflow split. Read-only mergeability succeeds; this is not a tested
merged-tree claim.

**Local CI attempt and continuation (not an uninterrupted green run):**
the new exact-head `just ci` passed formatting/static checks, workspace
and Tauri clippy, workspace Rust tests, **5,910 desktop tests**, desktop
production build and Tauri check. Its native main target finished
**3,073 passed / 1 failed / 19 ignored** (exit 101):
`cheap_discovery_reports_absent_before_any_forced_probe` saw a
process-global login-shell counter of 2 instead of 0. The counter
includes unrelated version/adapter probes whose tests do not hold the
failed test's PATH mutex; no managed-agent discovery implementation
changed in the runtime repair. The unchanged failing test then passed
**three isolated invocations**. Only the failed native workspace lane
was retried with `RUST_TEST_THREADS=1 just desktop-tauri-test`: **3,074
main-target tests passed / 19 ignored**, all additional workspace
targets passed (exit 0). The previously unrun `just web-build
mobile-test` tail then passed (exit 0; **2,019 mobile tests**). Earlier
successful lanes were reused; no source/guard changes or blanket CI
rerun. The original failure and all diagnostic/retry logs are retained.

- **71 native `nostr_convert` tests pass**, including seven new
production merge regressions: online/away/offline, missing/invalid
status, policy-only, status-less latest replacement and forged latest
replacement. Before production repair, those seven yielded **4 failures
/ 3 passing controls**.
- Reused frontend evidence from `ae23c1c9` (frontend is unchanged):
Desktop TypeScript and isolated E2E build pass; **9 browser tests / 0
retries**, covering both relay-only picker journeys and seven adjacent
stop-control regressions. Real UI with mock Tauri IPC, not live
relay/native webview.
- Earlier `ae23c1c9` local `just ci` passed without failures, including
3,067 native main-target tests / 19 ignored and 2,019 mobile tests; not
substituted for the new source gate above.
- Reused unchanged repair evidence: **17 real-store/hook history
regressions**, **161 focused tests**, and independent **9 mounted
owner/bot/identity revocation/regrant transitions** with zero hook-phase
native calls. The regression was falsified before repair (14 failures, 3
controls).
- Signed local-server fixtures cover discovery with no local/shared
record, ordinary-role membership, forged ownership, invalid signatures,
duplicate authentication, wrong-owner/latest-invalid policy, revoked
membership and wrong destinations. These establish native data checks,
not a live agent response.

GitHub checks and renewed technical/security review must apply to the
current published head; earlier-head green checks are not
replacement-head proof. Local source review is not formal
code-owner/latest-push approval or exact-range security authorization. A
green security workflow with substantive review skipped is not security
clearance.

### Screenshots

#### Relay-only picker evidence —
`ae23c1c9680a881cee7eed94e259bf15bf8ce3f7`

These cropped rows come from the two real production picker journeys in
[`owned-agent-discovery.spec.ts`](ae23c1c968/desktop/tests/e2e/owned-agent-discovery.spec.ts),
using mock Tauri IPC with **no local agents and no user-search
duplicate**. The fixture supplies verified-owner data and unknown
availability; the browser test checks its presentation, not native
signature verification. Both exact-tip journeys pass without retries. No
live relay, native webview, invitation, delivery or wakeup is claimed.

Before the repair, both relay-only candidate constructors discarded the
owner, so the existing “managed by you” label was absent. These are
after-repair captures; no before image was captured.

#### New Message → To
The relay-only agent retains its authenticated owner label.


![new-message-owner](https://raw.githubusercontent.com/block/buzz/536c6d3c90776cf85b1d5ce58666f2c8ad518829/pr-7122--new-message-owner.png)

#### Channel members → Add people and agents
The matching result retains “managed by you” beside the existing Add
action; the test does not click Add or claim membership changed.


![member-add-owner](https://raw.githubusercontent.com/block/buzz/536c6d3c90776cf85b1d5ce58666f2c8ad518829/pr-7122--member-add-owner.png)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-02 16:18:54 -04:00
Will Pfleger
0dbd036f5b feat(agents): harness-agnostic effort write path and spawn bridge (#4625)
## Summary

Genericizes the agent effort write-side so Goose participates in the
same canonical effort contract as buzz-agent. A spawn bridge translates
the canonical key to whatever the target harness expects at launch time.
Read/write/spawn paths all derive their vocabulary from runtime metadata
rather than a hardcoded buzz-agent list.

## What changed

### Rust — config bridge + spawn path

- `apply_spawn_effort_env` in `effort.rs`: production command-boundary
seam — writes baked env, runs the effort projection, strips per-runtime
suppress set, and emits exactly one projected key.
- `apply_effort_to_spawn_command` in `runtime.rs`: thin wrapper
returning a `#[must_use] EffortApplied(())` token (private field —
unforgeable outside the function). `spawn_agent_child` calls it as `let
effort = apply_effort_to_spawn_command(...)` and passes `effort` to
`spawn_with_effort_proof`. Deleting the call is a compile error:
`effort` is undefined at the `spawn_with_effort_proof` site. Deleting
`apply_spawn_effort_env` inside the wrapper turns the
production-sequence tests RED.
- `apply_record_field_updates` in `agent_models_update.rs`: returns
`Result<RecordFieldsApplied, String>` (`#[must_use]` token).
`update_managed_agent` calls it as `let applied =
apply_record_field_updates(...)?` then passes `applied` to
`stamp_record_updated_at`. Deleting the call is a compile error:
`applied` is undefined at the `stamp_record_updated_at` site.
- Unknown/custom-runtime passthrough: `apply_effort_launch_to_command`
skips the suppress loop when `preserve_passthrough && value.is_none()`,
preserving ambient ACP sentinels.
- `EnvVarGuard`: prior value stored as `OsString` (`var_os`) so
non-Unicode values are restored exactly on Drop. A single
`PROCESS_ENV_MUTEX` in `managed_agents/mod.rs` is shared by
`lock_path_mutex()` and `lock_env_mutex()` — any two tests calling
either helper are mutually exclusive with each other. Tests in other
modules (`app_state_tests`, `agent_config_tests`, `reader_tests`)
maintain their own independent locks and are not in this domain.
- Dead-code: `strip_effort_keys_from_command` marked `#[cfg(test)]`;
import path in `effort_cmd_tests.rs` fixed.
- Windows CI fix: platform-gated variants for inherited-env tests.

### TypeScript — renderer + model cleanup

- `AgentConfigFields` orphan-model cleanup effect: the
`isHarnessNativeEffort` early-return was skipping the model clear on
provider→Custom transitions. Refined to: return early only when model is
already null; clear model once while preserving the harness-native
effort key (Carl P2).
- Provider-empty convergence: when model is null and effort is native,
the cleanup effect returns early (nothing to clear) — prevents spurious
`onConfigChange` loop.
- `EffortSelectField` / `humanizeEffortLabel`: runtime-native option
labels title-cased (`off` → `Off`) with raw canonical values preserved
for round-trip fidelity.
- `AgentConfigFields`: drives effort renderer from
`selectedRuntime.effortCanonicalValues` (harness-native path) or the
model/provider catalog (buzz-agent/provider path), selected by
`isHarnessNativeEffort`.

### Docs

- `desktop/src/features/agents/AGENTS.md` item 14: updated from deleted
`persistAgentEffortLevel` direct-write contract to the shipped
Save-gated `update_managed_agent.effortLevel` path. Consistent with
`EffortPickerField`'s own doc comment.

### Tests

- `agent_models_update_tests.rs`: seam tests via
`apply_record_field_updates` — non-local rejects, local set/clear,
ordering invariant, ACP-sentinel sweep.
`record_field_updates_persist_effort_to_disk` (renamed from the prior
false-claim name) drives load→apply→stamp→save→load via a mock AppHandle
+ tempdir, asserting `effort_level` persists to disk. Manual HOME/XDG
restore replaced with RAII `EnvVarGuard` (panic-safe, `OsString`-exact).
- `effort_cmd_tests.rs` / `effort_tests.rs`: production-sequence seam
tests via `apply_effort_to_spawn_command`. Spawns `/usr/bin/env` to
verify child's real env. `EnvVarGuard` for panic-safe restore. Windows
twin using `cmd /c set`.
- `effortAutoClear.test.mjs`: five mounted stateful journeys via
`AgentConfigFields` with `useCustomSelect=true`. Covers: custom trigger
shows "Off" at mount; provider-empty mount is a stable fixed point;
provider→Custom switch converges; stale Anthropic model cleared on
Custom switch with Goose effort preserved (Carl P2 regression);
Settings-style Save/reread preserves effort.
- `agentDefaultsEditor.test.mjs`: two full Save/Next journey tests
through the real production parent trees. Both start with
`GOOSE_THINKING_EFFORT: "low"` and operate the real Popover-based effort
control (click trigger → click "off" option) before Save/Next, asserting
zero writes after selection. The `set_global_agent_config` stub captures
the submitted payload; each test asserts raw `GOOSE_THINKING_EFFORT:
"off"` in the captured config. The stub stores its canonical response
from the actual payload; the fresh remount's `get_global_agent_config`
returns that stored object (not a hand-written fixture), then asserts
"Off" shown. The `DefaultConfigStep` test starts with `isDirty: false` —
the real-control effort selection calls `onConfigChange → updateDraft →
isDirtyRef=true`, making the `commit()` on Next load-bearing.

## Mutation evidence

- Delete `let effort = apply_effort_to_spawn_command(...)` call from
`spawn_agent_child` → compile error: `error[E0425]: cannot find value
`effort`` at `spawn_with_effort_proof` site.
- Delete `let applied = apply_record_field_updates(...)?` from
`update_managed_agent` → compile error: `error[E0425]: cannot find value
`applied`` at `stamp_record_updated_at` site.
- Delete `apply_spawn_effort_env` from inside
`apply_effort_to_spawn_command` wrapper →
`production_sequence_goose_inherited_collision_resolved_in_child` RED.
- Revert `isHarnessNativeEffort &&` guard in cleanup `useEffect` to bare
`if (isHarnessNativeEffort) return` → stale model not cleared → Carl P2
regression test RED.
- Remove `isHarnessNativeEffort ||` from the nothing-to-clear condition
→ provider-empty mount emits `onConfigChange` → loop test RED.
- Remove `isHarnessNativeEffort` branch in
`AgentConfigFields.tsx:634-636` → both `agentDefaultsEditor.test.mjs`
mount assertions fail: trigger shows "Select" instead of initial effort
label.
- Remove `preserve_passthrough` guard in
`apply_effort_launch_to_command` →
`production_sequence_custom_inherited_acp_sentinel_survives` RED.
- Drop `GOOSE_THINKING_EFFORT` from the `set_global_agent_config` stub
payload → payload assertion in `agentDefaultsEditor.test.mjs` fails
(`undefined !== "off"`) → RED (verified).
- Remove the effort-select dirtying steps from the `DefaultConfigStep`
test (so `isDirty` stays false) → `commit()` is a no-op → write-count
assertion after Next fails (0 instead of 1) → RED.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-09-02 14:25:30 -04:00
klopez4212
114dbf745f Add voice notes to desktop messages (#6978)
## Summary

- add voice-note recording, preview, removal, and send controls to the
desktop composer
- render sent voice notes as waveform cards with scrubbing and
playback-speed controls
- transcode recordings locally into the existing relay video-media
contract, without relay changes

## Testing

- `just ci`
- `pnpm exec playwright test tests/e2e/voice-note.spec.ts`
- native voice-note media contract test against the relay validator

## Screenshots

Focused snapshots are attached below.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-09-01 16:36:37 +01:00
Salman Mohammed
59328d5ae3 feat(desktop): add thread-scoped ACP session experiment (#6909)
## Why

Thread-scoped ACP sessions from #6732 need an opt-in desktop rollout
that preserves today’s channel policy by default.

## What

- Add the default-off “Thread Scoped ACP Sessions” toggle under Settings
→ Experiments using the existing feature-override persistence.
- Map the persisted setting to `BUZZ_ACP_SESSION_POLICY=channel|thread`
for every local and provider-backed managed ACP launch. Changes apply
when managed agents next start; DMs remain conversation-scoped by the
backend.
- Keep the desktop setting authoritative over descriptor environment and
cover the UI, persistence, local spawn, provider payload, and existing
Projects/Workflows experiments.

## Risk Assessment

Low. The experiment defaults off and explicitly preserves `channel`;
changes are limited to managed-agent launch configuration. Existing
running agents are unchanged until their next start.

## Testing

- `. ./bin/activate-hermit && just ci` — passed on the final restacked
tree, including 5,674 desktop tests, 2,796 Tauri tests, and 1,860 mobile
tests.
- `cd desktop && pnpm build:e2e && pnpm exec playwright test
tests/e2e/experimental-features.spec.ts --project=smoke` — 1 passed.
- `. ./bin/activate-hermit && cargo test --manifest-path
desktop/src-tauri/Cargo.toml session_policy --lib` — 4 passed.
- `. ./bin/activate-hermit && cargo test --manifest-path
desktop/src-tauri/Cargo.toml commands::agents::deploy::tests --lib` — 15
passed.
- `. ./bin/activate-hermit && cargo test -p buzz-backend-kubernetes
--test wire_fixtures` — 4 passed.

## Stack Info

Stacked on #6732. This PR depends on #6732 and should merge after it.

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
Signed-off-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
Co-authored-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
2026-09-01 08:37:27 -05:00
Logan Johnson
4a9de1a3a1 feat(desktop): add isolated named demo builds (#6407)
🤖 I’m Larry, updating this description on Logan’s behalf.

## Summary

Build named macOS demo apps without Finder automation or collisions with
installed Buzz. `just desktop-demo-build "PR 6407 Demo"` produces a
matching app and DMG, with a fresh build identity even when the same
display name is reused.

- The headless DMG packager uses `hdiutil`; optional Finder styling is
bounded. The existing production release recipe is unchanged.
- Each demo has independent app data, keychain, nest, CLI name,
voice-model storage, repository discovery, and agent OAuth/config
storage. Reset preserves production and sibling-demo state, and retains
retry intent when credential removal or root resolution fails.
- Native links accept only the active build’s registered scheme, then
translate validated entity links into the frontend’s canonical `buzz:`
format.
- The recipe builds all six executable sidecars. Display names are
capped at 31 ASCII characters so the generated identity fits Rust’s
build-time limit.

**Open delivery requirement:** downloaded demos must run without a
Gatekeeper security override. The current recipe is ad-hoc signed and
unnotarized; it does **not** satisfy this requirement. Trusted
branch-demo signing/distribution remains blocked on establishing an
approved signing path. This PR is not being presented as complete
download-and-run delivery.

### Related issue

N/A — reported in the Buzz DMG-packaging workstream.

### Testing

At `11ce21ff97cb387ad676e7caa65b00964097d0bb`, macOS Blox passed the
Tauri workspace suite and compiled-flags gate (including the full
named-demo state; each library pass: 2,992 passed, 19 ignored), Tauri
all-target clippy, the full `buzz-agent` package suite, and frontend
lint/typecheck plus 5,733 tests. Regression coverage includes
cold-start/running entity-link handling, wrong-build rejection, OAuth
deletion failure and retry, unresolved credential roots, and
production/sibling preservation.

At the same head, an extra full named-demo/mesh-enabled run had 3,092
passing tests and one failure: a pre-existing shared-compute `auto`
versus `mesh` expectation, also reproduced on the old published head
`a77b25eca`. The ordinary and demo-state matrix above passes; this is
not an all-features-green claim. Live macOS Launch Services delivery
remains unverified.

GitHub CI completed with 30 successful checks and 9 skipped. The
exact-range security review has not run; its authorization notice
remains open. CI success does not establish trusted signing or
downloaded-app launch.

Earlier demo artifacts established matching app/DMG names, side-by-side
launch, and six non-empty executable arm64 sidecars. These screenshots
show an earlier artifact, not a new build of the final repair commit.
Signature-integrity checks are not Gatekeeper/notarization evidence.

<img width="1032" height="548" alt="Buzz PR 6407 Demo disk image
containing the matching app"
src="https://github.com/user-attachments/assets/bca0277e-db03-4308-b280-fcad55e6d601"
/>

<img width="1186" height="821" alt="Buzz PR 6407 Demo running alongside
other Buzz installations"
src="https://github.com/user-attachments/assets/b4bf4ae5-c341-4e15-8090-9d2ea7c623b6"
/>

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Co-authored-by: Larry <loganj+sandbox-larry@squareup.com>
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
2026-08-31 21:14:19 -04:00
thomaspblock
b47b5a5012 fix(desktop): back split thread headers (#7137)
## Summary
- render an auxiliary panel's requested header backdrop in docked/split
mode
- preserve explicit transparent-backdrop behavior
- cover a populated, scrolled thread pane so timeline content cannot
bleed through its header

## Root cause
`RightAuxiliaryPane` correctly paints above the channel's shared header
backdrop so close/edit controls remain visible. The docked
`AuxiliaryPanelHeader` branch, however, ignored its `backdrop` request,
leaving scrolled thread content in that higher stacking context
unbacked.

## Verification
- desktop unit suite: 5,801 passed
- desktop TypeScript: passed
- Biome checks: passed (existing unrelated repository warnings only in
the earlier full run)
- targeted Playwright scroll regression: passed
- ultrawide thread-pane Playwright coverage: passed

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
Co-authored-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
2026-08-31 17:29:31 -04:00
tulsi
cb3144999b add public descriptions to agent personas (#7126)
**Category:** new-feature
**User Impact:** People can add a short public description to an agent
and see what it does directly on agent cards and profiles.

**Problem:** Agent cards previously showed only a model label, so people
had to open an agent and inspect its instructions to understand its
purpose. Public metadata also needed one trustworthy lifecycle across
local edits, relay catalogs, profiles, and portable snapshots.

**Solution:** Add an optional owner-authored description with a
280-character visible-text policy, publish it as profile `about`, and
prefer it on agent cards while retaining the model fallback. Description
metadata is excluded from the spawn-content hash, remains
definition-owned, and is validated independently at every untrusted or
persistence boundary.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/commands/agent_config_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs**
Updates relay-directory profile test publication for the expanded
profile contract.

**desktop/src-tauri/src/commands/agent_models_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/agent_models_update.rs**
Preserves the effective `about` value when instance edits republish a
complete profile event.

**desktop/src-tauri/src/commands/agents.rs**
Carries the effective authored description into initial managed-agent
profile publication.

**desktop/src-tauri/src/commands/agents_profile.rs**
Adds `about` to profile reconciliation and keeps description, name, and
avatar synchronized against relay state.

**desktop/src-tauri/src/commands/agents_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/card.rs**
Materializes the definition-owned description before minting a portable
agent card snapshot.

**desktop/src-tauri/src/commands/personas/create.rs**
Normalizes and validates raw authored descriptions before persona
persistence.

**desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/inbound.rs**
Validates descriptions at inbound relay ingress and applies accepted
values to local definitions.


**desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/mod.rs**
Centralizes raw-byte validation followed by trim/empty normalization for
description writes.

**desktop/src-tauri/src/commands/personas/pending.rs**
Revalidates descriptions before preparing public persona publications.

**desktop/src-tauri/src/commands/personas/sharing.rs**
Carries the optional public description through this managed-agent
compatibility path.

**desktop/src-tauri/src/commands/personas/snapshot.rs**
Materializes definition-owned descriptions into portable instance
snapshots without creating a second persisted authority.

**desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/snapshot/import.rs**
Restores snapshot descriptions onto imported definitions while keeping
linked instance copies absent.

**desktop/src-tauri/src/commands/personas/snapshot/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/personas/update.rs**
Persists persona description edits, republishes linked profiles, and
preserves legacy avatars during complete kind:0 replacements.


**desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs**
Proves description-only profile sync does not write instance state or
clear a legacy avatar.

**desktop/src-tauri/src/commands/team_snapshot.rs**
Round-trips member descriptions through team snapshots and imported
definitions.

**desktop/src-tauri/src/commands/team_snapshot/tests.rs**
Covers team member description export and import fidelity.

**desktop/src-tauri/src/commands/teams/adopt/apply.rs**
Starts adopted team catalog members without synthesizing an unauthored
description.

**desktop/src-tauri/src/commands/teams/adopt/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/teams/pending/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/commands/teams/sharing/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/egress_guard_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/event_sync_team_catalog_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/agent_description.rs**
Defines the canonical Rust description resolution used by profile
publication and reconciliation.

**desktop/src-tauri/src/managed_agents/agent_events.rs**
Updates managed-agent record construction for the optional public
description field.

**desktop/src-tauri/src/managed_agents/agent_snapshot.rs**
Includes descriptions as snapshot profile `about` metadata and validates
them at decode ingress.

**desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs**
Updates managed-agent record construction for the optional public
description field.

**desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs**
Covers snapshot description export and rejection of unsafe or overlong
imported metadata.

**desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/definition_validation.rs**
Adds the shared 280-character visible-text policy for public
descriptions.

**desktop/src-tauri/src/managed_agents/discovery/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/effective_config/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/global_config/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/mod.rs**
Exports the description resolution and validation helpers to
managed-agent consumers.

**desktop/src-tauri/src/managed_agents/nest/render_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/parallelism.rs**
Updates managed-agent fixtures for the optional description field
without changing runtime configuration behavior.

**desktop/src-tauri/src/managed_agents/persona_events.rs**
Adds description to persona event content while deliberately excluding
it from the spawn-relevant content hash.

**desktop/src-tauri/src/managed_agents/persona_events/tests.rs**
Pins description event round-tripping and proves description-only edits
do not change the restart hash.

**desktop/src-tauri/src/managed_agents/personas.rs**
Initializes built-in persona records without authored descriptions for
backward-compatible defaults.

**desktop/src-tauri/src/managed_agents/personas/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/readiness.rs**
Updates managed-agent fixtures for the optional description field
without changing runtime configuration behavior.

**desktop/src-tauri/src/managed_agents/restore.rs**
Includes the effective description in launch-time profile
reconciliation.

**desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/runtime/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/team_catalog/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/team_snapshot.rs**
Updates managed-agent record construction for the optional public
description field.

**desktop/src-tauri/src/managed_agents/teams_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/managed_agents/types.rs**
Adds optional description metadata to persona and managed-agent records
and their compatibility projections.

**desktop/src-tauri/src/managed_agents/types/requests.rs**
Accepts optional descriptions on persona create and update IPC requests.

**desktop/src-tauri/src/managed_agents/types/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/migration_avatar_tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src-tauri/src/persona_catalog.rs**
Parses and validates descriptions at the untrusted community-catalog
boundary.

**desktop/src-tauri/src/persona_catalog_tests.rs**
Covers valid catalog descriptions plus rejection of malformed,
invisible, and overlong values.

**desktop/src-tauri/src/relay.rs**
Publishes and queries kind:0 `about` so relay profiles preserve authored
descriptions.

**desktop/src-tauri/src/relay/tests.rs**
Updates managed-agent/persona fixtures for the optional description
field while preserving the behavior under test.

**desktop/src/features/agents/AGENTS.md**
Documents description ownership, validation, snapshot, hashing, and
display invariants for future changes.

**desktop/src/features/agents/lib/agentDescription.test.mjs**
Pins Unicode counting, paste clamping, trimming, and empty
authored-description behavior.

**desktop/src/features/agents/lib/agentDescription.ts**
Provides shared display resolution, Unicode-scalar counting, and paste
clamping for descriptions.

**desktop/src/features/agents/lib/personaCatalogRelay.ts**
Maps validated catalog descriptions into catalog persona projections.

**desktop/src/features/agents/ui/AgentDefinitionDialog.tsx**
Adds the description draft to create and edit submission while
extracting identity fields from the large dialog.

**desktop/src/features/agents/ui/AgentDescriptionField.tsx**
Renders the public description input, helper copy, and Unicode-aware
near-limit counter.

**desktop/src/features/agents/ui/AgentIdentityCard.tsx**
Generalizes the card second line to show a two-line description or the
existing model fallback.

**desktop/src/features/agents/ui/UnifiedAgentsSection.tsx**
Prefers authored descriptions on persona cards and retains model labels
when no description exists.

**desktop/src/features/agents/ui/personaDialogState.test.mjs**
Verifies edit and duplicate drafts preserve authored descriptions.

**desktop/src/features/agents/ui/personaDialogState.ts**
Seeds authored descriptions into edit and duplicate dialog drafts.

**desktop/src/features/agents/ui/usePersonaActions.ts**
Preserves descriptions when copying catalog personas into local
definitions.

**desktop/src/shared/api/personaTypes.ts**
Defines description-bearing persona wire types in a focused module split
from the size-constrained API type file.

**desktop/src/shared/api/tauriPersonas.test.mjs**
Verifies raw persona descriptions map into the frontend model and absent
values become null.

**desktop/src/shared/api/tauriPersonas.ts**
Maps description fields across Tauri and preserves raw authored bytes
for authoritative Rust validation.

**desktop/src/shared/api/types.ts**
Re-exports the extracted persona types without changing consumer import
paths.

**desktop/src/testing/e2eBridge.ts**
Extends mock persona create, update, publication, and catalog parsing
with production-shaped description behavior.

**desktop/tests/e2e/agents.spec.ts**
Verifies an edited description persists and appears on the agent card.

</details>

### Reproduction Steps

1. Open **Agents**, edit a custom or built-in agent, and enter a
sentence in **Description**.
2. Save the agent and confirm the sentence appears as the second line on
its card.
3. Reopen the agent and confirm the authored description is restored;
clear it and confirm the card returns to the model label.
4. Paste more than 280 Unicode characters and confirm the field keeps
the first 280 characters and shows the near-limit counter.
5. Share or export/import the agent and confirm the description survives
in the catalog/profile or snapshot without showing a restart-required
badge for a description-only edit.

### Screenshots / Demo

The focused Playwright flow `built-in persona edits persist` exercises
the edited dialog, persisted value, and resulting card subtitle.
Screenshots can be added after review if the field placement or two-line
card treatment needs visual iteration.

### Verification

- `cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib` —
3,029 passed
- `cd desktop && pnpm test` — 5,805 passed
- `cd desktop && pnpm exec tsc --noEmit`
- Focused Playwright: `built-in persona edits persist` — passed
- Pre-push desktop, Tauri, typecheck, test, file-size, and branch-skew
gates — passed

---------

Signed-off-by: tulsi <tulsi@block.xyz>
2026-08-31 17:12:52 -04:00
Arjun Mahanti
5673c3307e feat(desktop): add protected-build Bestie experiment (#6902)
## Summary

Introduces a protected-build boundary for the default-off Bestie
experiment without adding any Bestie product surface.

- Official OSS builds select an empty protected-feature module and emit
no Bestie/Chief metadata or implementation content.
- Protected internal builds select a separate module graph containing
the Bestie experiment definition.
- Within an internal build, Bestie remains disabled until the user opts
in under Settings → Experiments.
- The production build runs an artifact matrix and fails if OSS output
contains protected content or internal output lacks the Bestie manifest.

## Build contract

| Build variant | User opt-in | Result |
| --- | --- | --- |
| Official OSS | Any/forged | Bestie absent from the compiled artifact |
| Protected internal | Off | Bestie available but disabled |
| Protected internal | On | Bestie enabled |

The companion protected-release change is squareup/buzz-releases#91. It
sets `VITE_BUZZ_BESTIE=1`, requires that exact value, forwards it into
the signed macOS build, and asserts the contract in release validation.

## Why this is separate

This gives later Bestie PRs one build-selected import seam. Protected
implementations must be reachable only from the internal module so they
never enter the official OSS module graph.

## Non-goals

- No Bestie persona or provisioning
- No sidebar, app-chrome, or message-toolbar UI
- No entitlement or secrecy claim: the source is public; this boundary
controls official Block artifacts

## Verification

- Exact commit `523cf49ced03cba9be43836a54d6aa5d6923cc82`
- Full `just ci`: 5,673 Desktop tests, 2,773 Tauri tests, 1,860 mobile
tests, Rust/Tauri/web/mobile static checks and builds
- OSS production artifact: scanner confirms no `Bestie`, `Chief of
Staff`, or `builtin:bestie` content
- Internal production artifact: scanner confirms the protected Bestie
manifest is emitted
- Both build orders verified; `dist` retains the requested variant for
Vite/Tauri packaging

---------

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
Signed-off-by: Fizz <fizz@buzz.local>
Signed-off-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Fizz <fizz@buzz.local>
Co-authored-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
2026-08-31 15:03:35 -06:00
Wes
2f3dd850db fix(relay): reject a frame on its own acknowledgement channel (#6961)
Pinky, an AI agent, updated this description on Wes's behalf after
taking over the startup investigation.

**Category:** fix

**User Impact:** An EVENT refused by WebSocket admission or handler
saturation receives a correlated `OK(event_id, false, reason)` instead
of an uncorrelated NOTICE, so the client can settle that refusal without
waiting for its publish timeout. Rate-limited refusals also arm client
backoff. This fixes a protocol failure mechanism; it does not establish
that every startup send will succeed or that the reported Desktop
startup incident is fully resolved.

**Problem:** Startup opens several live subscriptions and publishes at
once, and the relay's WebSocket admission gate is a fixed 5-second
window (`ws_admission_budget` = `human_ws_events_per_sec * 5`). If that
shared per-principal quota is exhausted, `enforce_ws_admission`
previously rejected an EVENT with a bare `["NOTICE", reason]`. Quota
pressure is a possible trigger, not proof of the original incident's
complete cause.

A NOTICE carries no event id. Both clients settle a pending publish
*only* from an `OK` keyed by event id (desktop `pendingEvents`, mobile
`_pendingEvents`), so nothing settled — and `handle_text_message`
returns early, so no `OK` ever followed either. The send **could not
fail**; it could only time out at `PUBLISH_TIMEOUT_MS` = 25s. That
explains how this rejection mechanism can produce a roughly 25-second
timeout; attributing the original report to it still requires the actual
startup/send workflow.

The handler-semaphore saturation path had the identical defect, and that
one needs no quota burst to fire.

**Solution:** NIP-01 gives each request type its own acknowledgement
channel, and a rejection is only actionable on the same one. Reject a
REQ with `CLOSED`, an EVENT with `OK(id, false, reason)`, and fall back
to `NOTICE` only where no per-request correlation exists. COUNT refusals
now also use `CLOSED(query_id, reason)` per NIP-45, covering both quota
admission and handler saturation (added in
`cd12c93804b87a24b61075dfd171dc471a0a527f`).

Reason strings are unchanged, so the `rate-limited:` prefix and `retry
in {N}s` hint that existing client gates parse keep working (desktop
`parseRateLimitHint`, mobile `RelayRateLimitGate`, buzz-acp
`set_rate_limit_gate`). Only the frame *type* changes, so
`docs/multi-tenant-relay.md` L7 stays satisfied.

Two notes on how this landed, both worth a reviewer's attention:

1. **A survived mutation became a design change.**
`send_admission_result` originally took a `RejectionTarget` parameter,
and reverting the *second* call site (the per-minute message quota)
survived the whole suite — with Redis unreachable the first quota check
short-circuits, so that line is unreachable in test. Rather than test
around it, the parameter is gone: the target is derived from the frame,
so no call site can name the wrong channel.

2. **The relay fix would have caused a client regression on its own.**
Gate arming lived only in the NOTICE branch. Once rejections arrive as
`OK:false`, `handleOk` failed the send without ever backing off — the
client would retry straight into the same quota. Desktop and Mobile now
arm on a `rate-limited:` OK rejection. ACP was subsequently fixed in
`3b06dd32493596ec650f20abf8805791c50fdc24`: it arms the gate and
re-parks only the refused observer frame, preserving other in-flight
frames. Desktop gets `activateRateLimitIfSignalled` as the single owner
of that prefix test, called from both `handleOk` and the NOTICE branch.

<details>
<summary>File changes</summary>

**crates/buzz-relay/src/rejection.rs** (new)
Owns the admission-rejection concern: `RejectionTarget`,
`rejection_target_for`, `request_rejection_message`,
`send_admission_result`, and `enforce_ws_admission`, moved out of
`connection.rs`. Six tests, two of which drive the real
`enforce_ws_admission` against a real `AppState`.

**crates/buzz-relay/src/connection.rs**
Fix the EVENT handler-semaphore rejection to correlate to the event id;
delegate admission to the new module. Add two tests that drive the real
`handle_text_message` with every handler permit held. Down from 1319 to
1116 lines.

**crates/buzz-relay/src/state.rs**
Widen the existing `test_state` helper to `pub(crate)` so the rejection
tests reuse it rather than adding a ninth copy of `AppState`
construction.

**desktop/src/shared/api/relayRateLimitGate.ts**
Add `activateRateLimitIfSignalled` — one owner for the `rate-limited:`
prefix test, since three inbound frame types now carry it.

**desktop/src/shared/api/relayClientSession.ts**
Arm the gate on a rate-limited OK rejection; route the NOTICE branch
through the same helper. Net zero lines, which keeps this
already-oversized file within the differential ratchet.

**desktop/src/shared/api/relayClientPublishRejection.test.mjs** (new)
Four tests against the real `RelayClient`: a rate-limited OK settles the
pending publish and arms the gate; an ordinary rejection does not arm
it; an accepted OK still resolves.

**mobile/lib/shared/relay/relay_session.dart**
Arm the gate in `_handleOk` for a rate-limited rejection.

**mobile/test/shared/relay/relay_session_test.dart**
Two tests driving the real `publish` + `debugHandleMessage` path.

</details>

<details>
<summary>Validation</summary>

**Mutation-tested — 5 mutations, all now killed.** Each production call
site was reverted to the defective behaviour to confirm a test fails.
This caught two false-negative tests:

| # | Mutation | Result |
|---|----------|--------|
| 1 | `rejection_target_for`: EVENT → `Connection` | 4 tests fail |
| 2 | EVENT handler-semaphore call site → bare NOTICE | **survived at
first** |
| 3 | per-minute quota call site → `Connection` | **survived**; fixed by
removing the parameter |
| 4 | desktop `handleOk` gate arming removed | 1 test fails |
| 5 | mobile `_handleOk` gate arming removed | 1 test fails |

Mutation 2 is the lesson: my first saturation test called
`request_rejection_message` directly, so reverting the real call site
inside the `match` arm left it green. It now drives
`handle_text_message` itself and dies on that mutation.

- `cargo test -p buzz-relay` — 928 passed, 1 failed:
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`,
**pre-existing**, reproduced with all changes stashed at `4dd4d73de`.
- `cd desktop && npm test` — 5721 passed, 0 failed (full suite).
- `cd mobile && flutter test` — 1876 passed, 0 failed (full suite).
- `just fmt-check`, `just clippy`, `just desktop-check`, `just
mobile-check`, `just file-size-check` — clean. Desktop's 5 biome
warnings are pre-existing (reproduced with changes stashed).
- All 9 pre-push lanes green, including `rust-tests` and
`desktop-tauri-checks`.

**Not verified:** not reproduced end-to-end against a live relay under a
forced quota burst. The causal chain is source-proven and
mutation-proven at the frame level; the ~25s attribution follows from
`PUBLISH_TIMEOUT_MS` but is not directly measured. A packaged-build
click-through would close that gap.

</details>

Related work: #6957 bounds Desktop HTTP event submission, but safe
retained-operation recovery after exhausted/ambiguous outcomes remains
unfinished. #6998 is the separately reviewable Desktop
readiness/duplicate-subscription slice. Neither is claimed to complete
native before/after startup-send validation.

Diagnosis note: `RESEARCH/DESKTOP_STARTUP_SEND_STALL_2026_08_27.md`
(Brain's workspace).

## Current review disposition (2026-08-28)

The [review on
`cd12c938`](https://github.com/block/buzz/pull/6961#pullrequestreview-5052902510)
identified ACP's missing rate-limited-OK handling. Commit
`3b06dd32493596ec650f20abf8805791c50fdc24` fixes gate arming, re-parking
the specifically refused observer frame, and the stale NOTICE comment.
Two regressions drive the real frame dispatcher. See [the implementation
and validation
response](https://github.com/block/buzz/pull/6961#issuecomment-5455032054).

The Mobile generation-check inline thread is resolved: its `async
publish` returns a failed Future when superseded; it does not throw
synchronously at invocation. No further production change was indicated
by that comment.

The validation counts above describe the original slice, not a new
rerun. At `3b06dd324`, the current GitHub check rollup has successful
completed test/build checks (non-applicable jobs skipped). The
security-review comment still requires review for the current base/head
range; do not read a green authorization job as a completed security
review. Approval and merge remain human decisions.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-31 14:43:31 -06:00
Arjun Mahanti
bc006f6708 feat: render agent avatars as squircles (#7106)
## Summary

- render every agent/AI identity as a 30% squircle across desktop and
mobile while keeping human avatars circular
- propagate agent identity through message, thread, profile, reaction,
member, DM, search, workflow, project, huddle, forum, pulse, and
agent-management surfaces
- preserve squircle geometry for fallbacks, focus/status treatments,
add-agent controls, and overlapping avatar outlines (`calc(30% + 2px)`
for the outer background)

### Related issue

None found. This change was requested and visually reviewed in the
originating Buzz thread.

### Testing

- `just desktop-test` — 5,799 passed
- `just mobile-test` — 2,008 passed
- pre-push gates passed at `0d59d77b120dcb90aac2f918e422c11c9fa5353b`:
desktop check, TypeScript typecheck, desktop full test suite, mobile
format/analyze and full test suite, Rust tests, Tauri checks, and
differential file-size gate
- deterministic desktop visual sweep covered channel messages/thread
summaries; thread, subthread, and sub-subthread depths; reactions and
reactor popovers; hover/full profiles; added-to-channel activity;
channel members/settings; agent library/team overlaps; agent creation;
mention autocomplete; and DM header/sidebar/settings

### UI evidence

The complete labeled visual matrix is available in the originating Buzz
review thread. GitHub-hosted copies will be added in a follow-up PR
comment using the repository screenshot script.

---------

Signed-off-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
2026-08-31 13:31:33 -04:00