Commit Graph
587 Commits
Author SHA1 Message Date
e2796d4a89 fix(desktop): virtualize channel member lists (#4991)
## Summary

- virtualize the unfiltered channel member roster instead of eagerly
mounting every member card
- retain the existing member search/add flow and archived-member
behavior
- cover a 500-member roster, bounded mounted rows, and scrolling to the
final member in E2E

## Cause

The members sidebar rendered every active member card at once. On large
channels this mounted hundreds or thousands of avatars, profile/presence
consumers, menus, and DOM rows, blocking the renderer even though
fetching the roster itself is fast.

## Testing

- `pnpm typecheck`
- `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx
tests/e2e/channels.spec.ts`
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members
sidebar (virtualizes large channel rosters|can invite relay-authorized
agents|can invite and remove managed agents|collapses same-persona
managed agents)'` (4 passed)
- pre-push: `desktop-check`, full `desktop-test` (4,371 passed),
branch-skew

Implemented by Carl on Wes's behalf.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 19:42:04 -07:00
16cc3de6d6 fix(desktop): enforce owner-only access in internal builds (#4053)
## Problem

Managed agents in internal Buzz builds should answer only their owner.
Previously, an agent could keep a broader access setting and respond to
other people, which did not match the access policy for internal builds.

This PR makes owner-only access effective for every managed agent in
internal builds and makes that restriction clear in the Desktop UI. Open
source builds remain configurable.

## Changes

- Enforce owner-only access when any managed agent starts or is deployed
from an internal build.
- Show the agent access control as locked to **Only me** in Desktop,
with an explanation of why it cannot be changed.
- Keep Welcome teammates working under the same rule without triggering
unnecessary restarts.
- Leave open source build behavior unchanged. This changes effective
runtime access without rewriting stored or relay-advertised settings.

The companion [#4064](https://github.com/block/buzz/pull/4064) explains
the restriction in-thread when someone without access mentions an agent.

The enforcement will remain inactive in shipped builds until
[squareup/buzz-releases#74](https://github.com/squareup/buzz-releases/pull/74)
marks internal releases during the build.

## Screenshots

| Before | After |
| --- | --- |
| ![Editable agent access control before the
change](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-before.png)
| ![Agent access locked to Only me in an internal
build](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-after-v2.png)
|

## Tests

Added coverage for:

- Runtime enforcement for [locally run
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/runtime/tests.rs#L196)
and [deployed
agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L510).
- The [current-build deployment
path](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L455),
[invalid stored
access](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L98),
and the [local startup
guard](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/env_vars/tests.rs#L149).
- Consistent enforcement across [both agent
backends](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L112).
- Welcome teammates created as [locally
run](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L384)
or
[deployed](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L393)
agents, including
[access-only](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L202)
and
[runtime-related](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L225)
restart behavior.

The full Desktop Rust and JavaScript suites, type checks, formatting,
clippy, and file-size checks passed. Playwright E2E was not run.

---

Originated from Buzz channel
[buzz-agent-control](buzz://channel?id=cf5dada7-e26a-4887-ae41-b3bd5f42d3b2).
Supersedes #2537.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: Amp <amp@ampcode.com>
2026-08-05 19:58:24 -06:00
Taylor HoandGitHub 5677e4ca05 test(desktop): match attachment button label (#4993)
**Category:** fix
**User Impact:** Pull requests can once again pass the Desktop smoke
test suite.
**Problem:** The inbox attachment-edit smoke test still looked for the
composer's former “Attach image” label after the shared action was
renamed to “Attach file,” causing shard 3 and the aggregate Desktop CI
job to fail on every PR.
**Solution:** Update the stale accessible-name selector to match the
current composer control while preserving the test's media-tag coverage.

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

**desktop/tests/e2e/inbox-edit.spec.ts**
Updates the attachment button selector to use the current accessible
label so the existing attachment-edit regression test reaches the
behavior it is meant to verify.

</details>

## Reproduction steps

1. Build the Desktop E2E application with `pnpm -C desktop build:e2e`.
2. Run `cd desktop && pnpm exec playwright test --project=smoke
tests/e2e/inbox-edit.spec.ts -g "editing an immediate attachment reply
preserves its media tags"`.
3. Confirm the test locates the “Attach file” control and passes.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 18:46:29 -07:00
eb6a37569d fix(desktop): enable message editing in Inbox (#2198)
### What changed?

Inbox detail now gives the current user's messages the same
ownership-gated Edit action as channel view. Editing reuses the existing
composer and mutation flow, preserves attachment metadata, and refreshes
structural overlays so the edited content appears immediately.

Foreign authors' messages remain non-editable, including grouped Inbox
conversations whose selected event is not the representative item.

| Own Inbox message exposes **Edit message**. | Saving the edit updates
the Inbox detail immediately. |
| --- | --- |
| ![Before: Edit message action in Inbox
detail](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-before.png)
| ![After: edited Inbox message
content](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-after.png)
|

### Why?

Inbox rows did not pass an edit handler into the shared message action
bar, so a user's own messages could be edited from channel view but not
from Inbox detail.

### How is it tested?

Desktop checks, unit tests, and the full local CI gate passed. The
focused Inbox Playwright regression passed 3 consecutive runs and covers
current-user edit/save, foreign and archived-channel denial, and
attachment preservation when a just-sent reply is edited before its
relay echo arrives.

Added tests:

-
[`inbox-edit.spec.ts`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/tests/e2e/inbox-edit.spec.ts)
-
[`inboxViewHelpers.test.mjs`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/src/features/home/lib/inboxViewHelpers.test.mjs)

*🤖 This PR was authored [with an
agent](buzz://message?channel=7f2d7e02-f4d5-4fb0-a426-0ca60ed3a1c3&id=c09ee04d18399b90296c3f932d22ab0377fa05f7e690ec7b08c36483ee633fbb).*

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
2026-08-05 17:00:23 -06:00
Taylor HoandGitHub 005fe54d02 fix(desktop): outline the selected community (#4969)
**Category:** improvement
**User Impact:** Selected communities now use a clear offset outline
without tinting or covering their icon.

**Problem:** The selected community state replaced the icon surface with
an accent fill, obscuring image icons and changing the tile's content
treatment. Hover also changed the fill, text color, shape, and opacity,
making navigation states visually jumpy.

**Solution:** Preserve each community tile's neutral surface and content
while using a primary CSS outline for selection and a lighter outline
for hover. The transparent outline offset leaves the space around image
edges unpainted, and adjusted spacing prevents neighboring outlines from
colliding.

<img width="200" height="152" alt="Screen Recording 2026-08-05 at 3 23
32 PM"
src="https://github.com/user-attachments/assets/5c25b1c0-4be8-41c4-8f1d-ad0010310c92"
/>


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

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Replaces selected and hover fills with offset outlines, keeps icon
presentation stable across states, and adjusts rail and tooltip spacing
for the new outline geometry.

**desktop/tests/e2e/community-rail.spec.ts**
Covers the shared active/inactive surface, radius, text color, opacity,
and outline behavior, including hover invariants.

</details>

## Reproduction steps

1. Run the desktop app with two or more communities.
2. Give the active community an image icon.
3. Confirm the active icon keeps its original image and receives a 2px
primary outline with a transparent 2px gap.
4. Hover another community and confirm only a lighter outline appears;
its fill, text color, opacity, and corner radius remain unchanged.
5. Switch communities and confirm the outline follows the active
community.

## Screenshots

**Full desktop context**

![Selected community outline in the desktop
app](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4969/selected-community-full.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 22:44:07 +00:00
Taylor HoandGitHub 24c7995740 fix(desktop): clamp thread panel to channel surface (#4965)
**Category:** fix
**User Impact:** Expanded thread panels now stay fully visible within
the desktop channel area instead of being cut off.

**Problem:** The resize handler clamped the thread panel against the
full window width, even though the panel renders inside a narrower
channel surface. On a 1720px window, this allowed a 1160px requested
width where only 1111px could render, leaving persisted and visible
geometry out of sync.

**Solution:** Clamp resizing against the measured channel-surface width
so the stored width matches what the layout can render while preserving
the minimum 300px main pane.

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

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Passes the measured channel-surface width into the thread-panel sizing
hook.

**desktop/src/shared/hooks/useThreadPanelWidth.ts**
Clamps drag-resize updates against the available channel width instead
of the full viewport.

**desktop/tests/e2e/threadpane-ultrawide.spec.ts**
Adds a 1720px regression proving the requested and rendered panel widths
match, while retaining the ultrawide expansion case.

</details>

### Reproduction steps

1. Open a channel thread in the desktop app at a 1720×900 window size.
2. Drag the thread panel's left resize handle toward the left edge to
expand it as far as possible.
3. Confirm the panel remains fully bounded inside the channel surface
and the main channel pane remains at least 300px wide.
4. Reload the channel and confirm the persisted expanded width renders
without clipping.

### Testing

- `pnpm --dir desktop build:e2e`
- `pnpm --dir desktop exec playwright test
tests/e2e/threadpane-ultrawide.spec.ts` — 2 passed
- Push hooks: `desktop-check` and `desktop-test` passed
- `git diff --check origin/main..HEAD`

### Screenshot

![Expanded thread panel remains bounded at
1720×900](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4965/threadpane-expanded-after-fix.png)

### Related issue

None found.

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-05 15:40:40 -07:00
d42d60d64e fix(desktop): rename generic attachment action from 'Attach image' to 'Attach file' (#2381) (#4304)
Fixes #2381.

## What was broken

The message composer's paperclip accepts generic attachments — images,
videos, PDFs, archives, and any other supported file — but its tooltip
and accessible name still read **"Attach image"**. Sighted users might
reasonably believe the control is image-only, and screen-reader users
get an incomplete description of what the button does.

## The fix

Rename the accessible name and tooltip text on the generic composer
paperclip in `MessageComposerToolbar.tsx`:

- `aria-label` — `"Attach image"` → `"Attach file"`
- `<TooltipContent>` — `"Attach image"` → `"Attach file"`

Plus update the 12 affected Desktop e2e selectors across five spec files
to reference the new accessible name:

- `desktop/tests/e2e/file-attachment.spec.ts` (2 selectors)
- `desktop/tests/e2e/spoiler.spec.ts` (2)
- `desktop/tests/e2e/composer-image-draw.spec.ts` (2)
- `desktop/tests/e2e/image-attachment-gallery.spec.ts` (4)
- `desktop/tests/e2e/video-attachment.spec.ts` (2)

## Scope (per the issue)

The feedback screenshot dialog
(`desktop/src/features/settings/ui/SendFeedbackDialog.tsx`) is
**unchanged** — that dialog itself is image-only, so its "Attach image"
wording is accurate. This PR only touches the generic composer control.

## Test plan

- All **105** unit tests in
`desktop/src/features/messages/ui/*.test.mjs` pass locally.
- Verified no remaining `"Attach image"` string outside the
intentionally preserved feedback dialog:
  ```sh
  grep -rn '"Attach image"' desktop/
  # → only hits in SendFeedbackDialog.tsx
  ```
- The six e2e specs are only exercised in CI; the selector updates are
mechanical and verified by grep to reference the new a11y name.

## Blast radius

- **Files touched**: `MessageComposerToolbar.tsx` (two strings); five
e2e spec files (12 selector updates).
- **User-facing behaviour**: one tooltip + one screen-reader name
change; no functional or visual changes otherwise.
- **No API or state change.**

## Out of scope

- The feedback dialog's "Attach image" wording — kept per the issue's
own "Scope" guidance.
- Any i18n plumbing — Buzz Desktop doesn't currently localize these
strings.

Signed-off-by: Sarthak Singh <sarthak.singh@juspay.in>
Signed-off-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Ravneet Arora <rarora@squareup.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:16:32 -07:00
719f9730d4 feat(desktop): allow leaving your final community (#3621)
**Category:** improvement
**User Impact:** People can leave their final Buzz community and return
to **Join or create a community** without losing their signed-in
identity.

**Problem:** Buzz Desktop blocked people from leaving when only one
community remained. Its existing remove action also changed local
configuration without ending relay membership.

**Solution:** Allow the final community to be left. Buzz now asks the
relay to end membership, removes the community locally only after
acceptance, and returns the person to the community selector while
keeping their identity signed in. If other communities remain, Buzz
switches to one of them. Relay rejection or timeout keeps the community
in place and shows an actionable retry error.

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

**desktop/src/features/communities/leaveCommunity.ts**
Adds signed kind 28936 publishing for active and inactive community
relays with actionable timeout handling.

**desktop/src/features/communities/leaveCommunity.test.mjs**
Covers event shape, relay selection, acceptance gating, rejection,
timeout messaging, and cleanup.

**desktop/src/features/communities/useCommunities.tsx**
Allows final-community removal and clears community-specific storage
without touching identity.

**desktop/src/features/communities/resolveCommunityRemoval.test.mjs**
Covers final, active, and inactive community removal state transitions.

**desktop/src/app/useCommunityNavigationTransitions.ts**
Gates local removal on relay acceptance and routes to a fallback
community or setup selector.

**desktop/src/app/AppShell.tsx**
Passes the asynchronous leave operation through shell entry points.

**desktop/src/features/communities/ui/EditCommunityDialog.tsx**
Replaces the local-only remove action with a pending-aware Leave
Community action that retains actionable errors.

**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Enables leaving the final community and carries the asynchronous
callback.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Carries the asynchronous leave callback through sidebar props.

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Enables leaving the final community from rail settings.

**desktop/src/features/sidebar/ui/SidebarProfileCard.tsx**
Carries the asynchronous leave callback through profile community
settings.

**desktop/src/testing/e2eBridge.ts**
Teaches the mock relay to accept NIP-43 leave events.

**desktop/tests/e2e/community-rail.spec.ts**
Updates leave interactions and verifies final-community setup
navigation, storage cleanup, and identity preservation.

</details>

### Reproduction steps

1. Run Buzz Desktop with a signed-in identity and one joined community.
2. Open Community settings and choose **Leave Community**.
3. Confirm the app shows **Join or create a community** and the existing
identity remains signed in.
4. Repeat with two communities and confirm leaving the active one
switches cleanly to the remaining community.
5. Reject or withhold the relay `OK` response and confirm the community
remains configured with an actionable error in the dialog.

### Test plan

- `pnpm check`
- `pnpm build`
- `pnpm test` (3,913 passing)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --grep "final community"`


<img width="557" height="316" alt="image"
src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-05 14:02:43 -07:00
f2ce575b62 Fix media attachment actions (#4849)
## Summary

- Upload photos immediately while keeping videos queued for background
upload.
- Move image annotation and video spoiler actions to thumbnail hover
overlays.
- Preserve the image editor's existing Draw and Spoiler controls.

### Snapshots

#### Image annotation overlay

![Image annotation
overlay](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--01-image-annotation-overlay.png)

#### Image editor controls

![Image editor
controls](https://raw.githubusercontent.com/block/buzz/87d7e1b1a0774ffef7a1a6cba03baffd63e11bd4/pr-4849--02-image-editor-controls.png)

## Testing

- `pnpm typecheck`
- `pnpm check`
- Focused attachment, drawing, and spoiler smoke tests
- Pre-push desktop tests (4,286 passing)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Honey <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 12:17:48 -07:00
klopez4212andGitHub 2034e693a8 fix(desktop): remove join API token control (#4897)
## Summary

Remove the nonfunctional API-token option from the existing-community
join flow.

## Validation

- Focused Playwright join-flow coverage
- Add-community screenshot coverage

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-05 11:56:14 -07:00
014562c063 fix(desktop): allow shared agent mentions (#4913)
## Summary

- admit relay-discovered agents to autocomplete when their response
policy authorizes the viewer
- require authorization in the exact active stream/forum channel for
mentions, while keeping community-wide discovery for member invitation
- fail closed for relay-only agents in DMs and unresolved composer
contexts
- re-authorize cached autocomplete rows after policy/channel changes so
stale agent suggestions cannot leak back in
- preserve managed-agent behavior and explicitly reject stale
agent-marked channel members absent from both live directories

## Validation

- `pnpm --dir desktop test` — 4,288 passed
- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop check`
- `pnpm --dir desktop build:e2e`
- focused Playwright mention matrix — 12 passed
- focused Playwright member-invitation matrix — 2 passed
- pre-push hooks after rebase to current `origin/main` — desktop check
and 4,288 tests passed
- independent correctness/privacy re-review cleared with no remaining
blocker

## Related competing PRs

This supersedes or overlaps #2333, #3056, #4242, #4137, #2314, #4058,
and #2605. This version adds exact-channel authorization, fail-closed
DM/context handling, cached-row reauthorization, forum coverage,
outbound mention-tag coverage, explicit stale-member coverage, and
add-member discovery coverage.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-05 11:15:11 -07:00
efe1893dd3 fix(channels): restrict private-channel invitations (#4612)
This change requires an active owner or administrator for third-party
additions to private channels. The relay validator and transactional
database authority enforce the same rule, including removed-member
reactivation and role-change paths.

Idempotent self-target behavior remains available, while ordinary
members can no longer extend private-channel access to another identity.

## Testing

- `git diff --check
origin/main...codex/security-private-channel-invite-authority`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
2026-08-05 10:47:00 -07:00
25a9cf1be6 feat: paste composer text without formatting (#4801)
## Summary

- handle Cmd+Shift+V on macOS and Ctrl+Shift+V on Windows/Linux in the
message composer
- read plain text through the native Tauri/arboard clipboard path in
packaged builds, with a browser-only Clipboard API fallback
- re-enter ProseMirror's paste pipeline with populated `text/plain`
clipboard data so selection, undo, multiline behavior, and paste
observers remain intact
- cover both platform mappings with rendered composer E2E tests that
assert the native command path

## Testing

- `pnpm test` — 4,286 passed
- `pnpm check`
- `pnpm typecheck`
- `pnpm exec playwright test composer-selection-formatting.spec.ts
--project=smoke` — 26 passed
- `cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace
--all-targets --target aarch64-apple-darwin`
- `just desktop-tauri-test` — 2,206 core tests plus integration and
doc-test groups passed
- full pre-push hooks passed

## Manual verification

Physical packaged-app clipboard verification remains recommended on
macOS, Windows, and Linux. The automated E2E uses mocked Tauri IPC but
asserts the native `read_clipboard_text` command is invoked.

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-04 16:16:14 -07:00
e30db7028f feat(projects): support multiple repositories (#4671)
## Summary
- adopt the finalized NIP-MP project model so one project can enumerate
and switch between multiple NIP-34 repositories
- add project and repository navigation, activity summaries,
existing-repository attachment, and repository access-channel management
- preserve privacy-safe activation provenance for agent-authored
patches, pull requests, issues, and associated commits

## Test plan
- [x] Run desktop typecheck and unit tests
- [x] Run focused NIP-MP, repository access, and provenance tests
- [x] Run Rust formatting and desktop lint checks
- [x] Run the complete pre-push suite after merging current `main`
- [ ] Manually verify project creation, repository attachment,
switching, and access repair on staging
- [ ] Manually verify public-channel and private-agent origin labels on
newly created Git activity

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

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-04 17:30:12 -04:00
7bcfe7e0a1 fix(desktop): widen post-Enter timeouts in empty-edit-delete spec (#4792)
## Summary

Increases three Playwright assertion timeouts in
`tests/e2e/empty-edit-delete.spec.ts` from 5s to 10s to fix a
shard-composition flake introduced by PR #4694.

## Root Cause

PR #4694 added `huddle-transcription.spec.ts` (477 lines, 22+ tests) to
the Desktop Smoke E2E suite, shifting shard 2 composition so that
`empty-edit-delete` now runs with significantly more accumulated browser
state. The three affected assertions all wait for a React state update
triggered by pressing Enter in edit mode:

- `alertdialog` becoming visible after an empty edit (tests 1 and 2)
- `edit-target` hiding after a successful non-empty edit (test 3)

These transitions go through the React scheduler. In isolation they
complete in milliseconds. In a loaded headless shard with accumulated GC
pressure, the 5s window became insufficient — test 3 failed 3/3 times in
CI run
[30946444168](https://github.com/block/buzz/actions/runs/30946444168)
with `edit-target` still visible after Enter.

No product code is changed. The empty-edit-delete flow is correct and
untouched by #4694. This is a test-environment timing adjustment only.

## What Changed

- `tests/e2e/empty-edit-delete.spec.ts` — three `{ timeout: 5_000 }` →
`{ timeout: 10_000 }` for the post-Enter React-update waits

## Validation

- `just desktop-check` — passed
- `just desktop-test` — 4194 passed, 0 failed

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-04 17:08:29 -04:00
8b8d86c5d2 fix(desktop): integer-align custom reaction emoji (#4779)
## Summary

- remove the fractional half-pixel translation from custom reaction
emoji
- preserve the existing 28px reaction pill, 14×14 glyph box, and
`object-fit: contain`
- add real-app Playwright coverage for integer centering and non-square
intrinsic dimensions

### Related issue

None found. Follow-up to the Buzz emoji-warp investigation.

### Testing

- `cd desktop && pnpm exec playwright test
tests/e2e/custom-emoji.spec.ts --project=smoke` (15 passed)
- `cd desktop && pnpm test` (4,171 passed)
- `cd desktop && pnpm lint` (passed; two pre-existing informational
`useTemplate` diagnostics)
- `cd desktop && pnpm typecheck` (passed)
- `cd desktop && pnpm exec biome check
src/features/messages/ui/MessageReactions.tsx
tests/e2e/custom-emoji.spec.ts` (passed)

Independent review also mutation-tested the regression coverage by
restoring the half-pixel transform and confirming the new test fails. No
after screenshot is included because the patch preserves dimensions and
fixes subpixel raster alignment; the real-app test asserts the mechanism
directly.

Validated at `bc95969b21b58d83b7f94de4ad25e499e52b35fb`.

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
2026-08-04 20:38:19 +00:00
ce3cf3cd25 Polish Huddle voice controls (#4694)
## Summary

- add a visible Stop control for interrupting agent speech
- make push-to-talk available by default while preserving manual mute
controls
- refine agent management, muted audio states, drawer layering, and
return navigation
- suppress duplicate notification sounds for Huddle messages

## Why

Huddles could trap users behind long agent speech, hide useful agent
controls, and leave temporary Huddle state visible after the call. The
drawer also regressed when the terminal substrate began painting behind
the rounded app surface.

## Validation

- `just desktop-ci`
- focused Huddle Playwright coverage for the drawer, speech
interruption, agent picker, and leave navigation

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-04 13:07:50 -07:00
5179726737 fix(local-archive): default both archive settings to enabled (#4750)
## Overview

Both local archive settings — "Archive my agents' observer frames" (kind
24200) and "Archive my agents' turn metrics" (kind 44200) — previously
defaulted to OFF in OSS builds, controlled by build-time env vars. This
had an irreversible cost: observer frames are ephemeral (not stored by
the relay), so any missed events are permanently unrecoverable. This PR
makes both settings default to enabled for all builds and removes the
build-time flag machinery entirely.

## What changed

### Rust

- `observer_archive_default_enabled()` — returns `true` unconditionally;
removed `option_env!("BUZZ_DESKTOP_BUILD_OBSERVER_ARCHIVE_DEFAULT")`
check and `nest_is_dev()` runtime fallback.
- `agent_metric_archive_default_enabled()` — returns `true`
unconditionally; removed
`option_env!("BUZZ_DESKTOP_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT")` check
and its OSS-build test.
- `build.rs` — removed both `rerun-if-env-changed` declarations
(`BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT`,
`BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT`) and the two baked-env
emitting blocks.

### Build / CI

- `Justfile` — removed `desktop-tauri-test-compiled-flags` recipe (the
dual-compile test machinery).
- `.github/workflows/ci.yml` — removed the "Desktop Tauri compiled-flag
verification" CI step.

### TypeScript

- `useObserverArchiveSeed.ts` — removed `observerArchiveDefaultEnabled`
dep from `ObserverArchiveSeedDeps` and the `policyOn` gate in
`reconcileObserverArchive`; the function now unconditionally calls
`mergeSaveSubscriptionKinds`.
- `useAgentMetricArchiveSeed.ts` — removed
`agentMetricArchiveDefaultEnabled` dep from `AgentMetricArchiveSeedDeps`
and the `defaultOn` flag-check path in `maybeSeed`; the
`hasExplicitChoice` guard is preserved as the sole gate against
re-seeding.
- `LocalArchiveSettingsCard.tsx` — removed `policy` prop,
`observerPolicy` state, and `observerArchiveDefaultEnabled` fetch from
`ObserverArchiveSection`; toggle is now always enabled (just `toggling`
disables it); removed the stale "Always on for internal builds" copy
branch; removed the `observerPolicy !== false` guard from
`handleObserverToggle`.
- `tauriArchive.ts` — updated JSDoc on both default-enabled functions to
reflect always-true.
- `e2eBridge.ts` — changed both mock defaults from `?? false` to `??
true` so E2E tests without an explicit mock override exercise the real
default behavior.

### Tests

- `useObserverArchiveSeed.test.mjs` — replaced `policyOn` dep with
direct merge dep; updated `test_oss_policy_off_no_merge` →
`test_reconcile_always_seeds_24200`; all cancellation, identity-switch,
and ordering tests adapted.
- `useAgentMetricArchiveSeed.test.mjs` — removed `defaultOn` dep and
`test_oss_build_does_not_seed`; updated
`test_internal_build_unset_seeds_*` → `test_default_enabled_*`;
`hasExplicitChoice` guard tests unchanged.

## Preservation of explicit opt-outs

Users who have previously toggled the setting off are unaffected:

- `useAgentMetricArchiveSeed` skips seeding when
`hasExplicitChoice(pubkey)` returns true (localStorage-persisted per
identity).
- Observer archive reconciliation now unconditionally calls
`mergeSaveSubscriptionKinds`, but a user who already deleted the
subscription can turn it off via the Settings toggle, which calls
`removeSaveSubscriptionKind` — this is the existing explicit opt-out
path, and the toggle is now always enabled (not locked by a policy
flag).

## Result

- No `BUZZ_BUILD_*_ARCHIVE_DEFAULT` /
`BUZZ_DESKTOP_BUILD_*_ARCHIVE_DEFAULT` references remain outside
CHANGELOG/history.
- Desktop node tests: 4168 pass, 0 fail.
- `just desktop-tauri-check`: clean.
- `just desktop-tauri-test`: all pass.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-04 16:02:34 -04:00
e5efd04705 fix(desktop): close reconnect gaps that previously required CMD+R (#4737)
> Opened by Brain (agent) on behalf of @wesbillman.

## Problem

Users report the desktop app doesn't reliably reconnect and can wedge in
states where only CMD+R (or a full restart) restores connectivity
(thread `c2205e2b` in #desktop-reconnecting).

Pinky's empirical light-switch matrix (real `buzz-relay`, SIGTERM/1012 +
SIGKILL × 1s/45s/3min, at `f18a9cb10`) passed 4/4 — the backoff state
machine recovers cleanly from ordinary relay loss. That isolates the
user-stuck states to four special cases a reload resets but the auto
flow never did.

## Fixes

| Gap | Change |
|---|---|
| **G1** — recovery rode solely on the backoff timer (max 30s),
throttled by WKWebView in occluded/background windows; nothing fired on
network return or wake | New `useRelayResumeTriggers`: `online`, window
focus, and visibility→visible call `preconnect()` when the session is
`reconnecting`/`stalled`, rate-limited to one attempt per 5s
(`relayResumeTriggerPolicy.ts`). Deliberately inert for the terminal
`disconnected` state. |
| **G2** — any AUTH `OK false` latched the session terminal forever,
though the relay also rejects for transient causes (duplicate-AUTH
"already authenticated" race, ±60s clock skew, fail-closed allowlist DB
errors) | New `AuthOkTracker` (`relayAuthPolicy.ts`): "already
authenticated" resolves as success; transient rejections retry with
normal backoff; latch only on `restricted:` or after 3 consecutive
rejections. |
| **G3** — an `auth-required:` CLOSED (REQ racing AUTH after reconnect)
permanently deleted the live subscription with no UI signal — frozen
channel while state reads "connected" | Reclassified `auth-required:` as
retryable in `relayClosedPolicy.ts`. Genuinely terminal classes
(`restricted:`, `invalid:`, …) still delete. Can't loop: a truly
unauthenticated session latches terminal at the connection level. |
| **G4** — `useRelayAutoHeal` observed the 2s-debounced connection hook,
so sub-2s flaps never triggered the heal even though `resetConnection`
had already rejected every in-flight query | Auto-heal now observes the
raw connection-state emitter. The existing 15s heal rate-limit still
guards against flap storms. |

Each fix is a colocated pure-policy module + unit tests, matching the
existing `relayReconnectPolicy`/`relayClosedPolicy` pattern.

## Validation

- Full desktop unit suite: **4151 pass, 0 fail** (at branch tip, `pnpm
-C desktop test`)
- `pnpm -C desktop typecheck` and `pnpm -C desktop check` clean
(file-size ratchet respected — `relayClientSession.ts` net −2 lines
despite the tracker wiring)
- Evidence trail: `RESEARCH/DESKTOP_RECONNECT_CMDR_GAP_AUDIT.md`
(audit), `RESEARCH/DESKTOP_RECONNECT_LIGHT_SWITCH_RESULTS.md` (Pinky's
matrix)

## Not covered / follow-ups

- Native macOS sleep-wake was not automated (would kill the harness
session); G1's focus trigger is the mechanism that covers wake in
practice, but a manual sleep-wake verification on a real build is
worthwhile.
- G3 terminal-CLOSED classes (`restricted:` etc.) still silently delete
subs with no UI signal — surfacing that is a separate UX decision.
- Stall-watchdog latency (60s idle + 10s check) left unchanged; G1
triggers largely mask it.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
Co-authored-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
Co-authored-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
2026-08-04 18:33:42 +00:00
cb4a73e17d Dock Buzz Term within channel workspace (#4724)
## Summary

- replace Buzz Term's full-app takeover with a resizable bottom dock
inside the channel content surface
- add a discoverable channel-header button plus hide and
maximize/restore controls
- create PTYs lazily and keep separate, persistent terminal workspaces
per channel
- capture immutable channel/thread context on every terminal session

## Multiple-channel behavior

The dock is a single surface, but its tabs are partitioned by channel.
Switching channels swaps to that channel's sessions without terminating
background PTYs; returning restores them. New tabs capture the currently
visible channel/thread context.

## Verification

At commit `7ca087f8e08c80528387684364a65bf4ccd6315f`:

- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop test` — 4,129 passed
- pre-push repository hooks — desktop check/test, Tauri checks, terminal
Rust suites all passed

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Co-authored-by: kenny lopez <klopez4212@gmail.com>
2026-08-04 11:10:47 -07:00
klopez4212andGitHub e1287c92cc Refine community invite links (#4734)
## Summary

- Separate direct invites from link sharing with a labeled divider.
- Show the generated invite URL inline with truncation and a copy
control.
- Use shared loading feedback and a restrained copy-status resize.

## Validation

- `pnpm -C desktop exec playwright test
tests/e2e/invite-link-copy.spec.ts
tests/e2e/invites-settings-screenshots.spec.ts` (7 passed)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-04 10:39:09 -07:00
f86dfc5883 feat(desktop): surface config diff in restart-required badge (#3637)
The "Restart required" badge reports that an agent's running config has
drifted from its spawn-time config, but never says what changed. This
ships the full feature: a typed Rust diff engine and a TS/UI layer that
renders it at every badge site.

## Rust core (spawn-snapshot diff engine)

Replaces the lossy `u64` `spawn_config_hash` with a typed
`SpawnConfigSnapshot`. The snapshot is stamped from the already-resolved
command/env/config values immediately before `spawn()`, closing the race
window where a mid-spawn config edit would suppress the badge.

`SpawnConfigSnapshot::canonical()` is the single JSON projection shared
by the badge and the diff. Drift is `to_value(stamped) !=
to_value(current)`; the diff is a generic leaf walk over those same two
values, so badge-on and diff-non-empty are structurally guaranteed.
Adding a snapshot field reaches the UI with no code change to the diff
engine — `mutation_table_covers_every_serialized_field` fails CI if a
new field arrives without a mutation row.

`eligible_restart_diff(persona_orphaned, Option<TrackedSpawnState>)`
returns the final vector — snapshot walk entries plus a synthetic
`adapter_availability` entry. It returns empty for an orphaned instance
(spawning one would fail) and for agents with no tracked spawn state
(never stamped, can never have drifted). `needs_restart =
!restart_diff.is_empty()` derives from that vector and nothing else.

Redaction policy (`policy_for(path)`) is shared by the wire diff and the
snapshot's manual `Debug` via `is_safe_to_reveal()` from
`managed_agents::env_vars` as the single authority for env-key masking:

| Policy | Paths | Rendering |
|---|---|---|
| `Text` | `system_prompt`, `team_instructions` | character counts only
|
| `MaskedBare` | `args`, `relay_url` | `••••`, no suffix |
| `MaskedSuffix` | non-allowlisted `env.*` | `••••` + last 4 chars when
longer than 8 |
| `Plain` | allowlisted `env.*` (`BUZZ_AGENT_THINKING_EFFORT`,
`BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL`, `DATABRICKS_HOST/MODEL`) and
everything else | verbatim |

Default-deny: every env key not in the explicit allowlist stays masked.
`is_safe_to_reveal()` is the single allowlist authority for both the
baked-env display and the diff.

`restart_diff` is omitted from the wire when empty
(`skip_serializing_if`).

## TypeScript / UI layer

New `restartDiff.ts` module defines `RestartDiffEntry`, `RestartChange`,
`JsonValue`; `tauri.ts` and `types.ts` re-export and add `restart_diff`
/ `restartDiff` fields (Rust omission → `restartDiff: []`).

**`RestartDiffBadge`** — hover tooltip capped at 6 entries + "and N
more", `asChild` span trigger (never inside a `<button>`), auto-restart
blurb below the diff list (on/off variant from `autoRestartEnabled`
prop; same `AUTO_RESTART_ON_BLURB` / `AUTO_RESTART_OFF_BLURB` constants
shared with the Runtime-tab banner). **`RestartDiffList`** renders the
full uncapped list for the Runtime-tab banner with `tooltip`/`inline`
presentation variants for correct foreground in both surfaces.

**`ManagedAgentRow` B4 fix** — badge moved to a sibling `div` of the row
expansion button; tooltip trigger has no `button` ancestor.

**`UnifiedAgentsSection`** — both badge sites render
`<RestartDiffBadge>` instead of a raw `<Badge>`, with
`autoRestartEnabled` threaded from `agent.autoRestartOnConfigChange`.

**Side-panel fix** — `RestartDiffBadge` rendered tab-independently in
the `ProfileSummaryView` hero area (was Runtime-tab only — root cause of
the ~50% inconsistency Will reported). Hero badge is `self-center` in
the flex column. `ProfileRuntimeTabContent` early-return checks
`needsRestart` so the banner is never dropped when all other content is
empty. Auto-restart blurb in the Runtime-tab banner uses the shared
constants.

## Wire shape

```jsonc
"restart_diff": [
  { "field": "model",              "change": { "kind": "value",  "before": "gpt-5", "after": "claude-4" } },
  { "field": "system_prompt",      "change": { "kind": "text",   "before_chars": 1234, "after_chars": 1410 } },
  { "field": "env.OPENAI_API_KEY", "change": { "kind": "masked", "before": "••••bc12", "after": "••••xyz9" } },
  { "field": "env.BUZZ_AGENT_THINKING_EFFORT", "change": { "kind": "value", "before": "medium", "after": "high" } }
]
```

`added`/`removed` occur only for dynamic-map keys; nullable struct
fields always serialize as `null`; arrays are atomic leaves (`args`,
never `args.0`).

## Tests

**Rust** — 1902 passing: snapshot mutation coverage, diff entry
serialization, allowlist-aware env masking
(`allowlisted_env_key_shows_plain_value`,
`allowlisted_env_key_is_case_insensitive`,
`non_allowlisted_env_key_stays_masked`),
`unstamped_agent_yields_no_badge_and_no_entries` (both orphan values),
`summary_without_drift_omits_restart_diff_from_the_wire`,
`unstamped_availability_is_not_drift`. Clippy clean, fmt clean.

**TypeScript** — `needs-restart-screenshots.spec.ts`: 11 E2E cases
registered in the smoke project — all three badge sites, tooltip +
keyboard focus, DOM no-button-ancestor assertion, 6+1 truncation,
uncapped Runtime list, unknown field humanisation, side-panel badge on
default Info tab, inactive/friendly-error Runtime opening path.

Consolidates [#3652](https://github.com/block/buzz/pull/3652)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-04 12:21:59 -04:00
klopez4212andGitHub 540b58920c Polish sidebar unread hierarchy (#4573)
## Summary

- Keep selected sidebar rows regular by default; manually unread rows
become bold immediately.
- Apply a clearer dark-mode hierarchy: standard inactive rows at 75%,
muted rows at 45%, and unread rows at full emphasis.
- Keep hover text color stable while retaining the selected-row and
unread cues.

## Validation

- `pnpm typecheck`
- `pnpm build:e2e`
- Playwright: sidebar badge and channel-mute coverage

## Screenshots

Posted in the PR comments.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-04 15:39:45 +00:00
Luke TornquistandGitHub d0d4acd4fa fix(desktop): show cached display names on startup (#3317)
## Why

Buzz restores cached channels and messages before profile lookups
complete. On launch, that briefly exposes pubkey-derived labels in place
of familiar display names.

## What

- Persist a bounded, relay-scoped cache of last-known display names,
NIP-01 names, and NIP-05 handles
- Seed batch profile queries from those labels immediately, while
keeping them stale so the existing relay request revalidates them
- Keep cached data presentation-only: avatars and ownership metadata are
not persisted or used to seed profile-detail caches
- Remove cleared or missing profiles, purge a relay's labels when its
community is removed, and include the cache in local-storage quota
recovery
- Add unit coverage for parsing, bounds, eviction, malformed data, and
cleared profiles
- Add an E2E regression that delays the relay profile response and
verifies the cached name is rendered first

## Risk Assessment

Low. The cache is disposable, capped at 1,000 entries per relay, scoped
by normalized relay URL, and always revalidated. It contains only public
label fields and does not restore avatars, agent ownership, or
authorization state.

## Verification

- `just ci`
- `pnpm typecheck`
- `pnpm test` — 3,727 passed
- `pnpm exec playwright test tests/e2e/channels.spec.ts --grep "cached
profile labels"` — passed

Generated with Codex
2026-08-04 11:36:42 -04:00
klopez4212andGitHub d0af845a1d Remove blur from Welcome composer guidance (#4691)
## Summary
- Keep the Welcome composer prompt above the dock blur so it stays
readable.
- Remove blur from the prompt and persona-motion paths.
- Cover the crisp, correctly layered banner in the onboarding browser
test.

## Validation
- `pnpm -C desktop exec biome check
src/features/channels/ui/WelcomeComposerBanner.tsx
tests/e2e/onboarding.spec.ts`
- `pnpm -C desktop build:e2e`
- `pnpm -C desktop exec playwright test tests/e2e/onboarding.spec.ts
--grep "finishing onboarding creates starter channels and focuses
welcome-everyone for a new member" --project=integration`

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-04 07:14:09 -07:00
klopez4212andGitHub a5bf3c5ae1 Refine desktop timeline activity presentation (#4582)
## Summary

- Make channel join/leave activity use the selected inline avatar-stack
treatment.
- Group related membership activity for one hour and preserve
profile/overflow-name interactions.
- Restore the virtualized day-divider handoff and align the sticky date
behavior with the message timeline.

## Validation

- `pnpm check`
- `pnpm test`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml`
- Visual desktop screenshot captured with seeded membership activity

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-04 07:11:16 -07:00
f18a9cb106 Defer desktop media uploads until send (#4522)
## Summary

- send desktop messages immediately while media uploads continue in
background state across channel navigation
- show immediate progress above the composer and keep Jump to latest
above it
- report the real media stages as Preparing, Processing, Converting,
Uploading, and Finishing
- use Buzz's shared spinner during local media work, then switch to the
real percentage when byte transfer begins
- animate phase-label and status-suffix changes without overlap or
layout jumps
- keep cancel, progress fill, message publication, and community-reset
behavior coordinated with the background task
- use raw Tauri IPC for large browser files so renderer-side byte
serialization does not block initial feedback

## Why

Desktop previously blocked sending while attachments uploaded in the
composer. Large videos could also pause the renderer before progress
appeared, and the progress pill said Uploading while native media
processing was still underway. This makes the initial response immediate
and describes the work actually happening.

## Validation

- `cd desktop && pnpm check`
- `cd desktop && pnpm typecheck`
- `cd desktop && pnpm test` (3,931 passed)
- `cd desktop && pnpm exec vite build --mode e2e`
- `cd desktop && pnpm exec playwright test
tests/e2e/file-attachment.spec.ts --project=smoke` (11 passed)
- focused native media tests (80 passed)
- native Clippy with all targets and features
- pre-push native suite (2,107 passed, 14 ignored; 3 diagnostics passed)

Updated phase snapshots are included in the PR comments.

Split from #4512 so the desktop and mobile changes can be reviewed
independently.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-04 06:59:43 -07:00
631b05c883 feat: ship Buzz Term (#4347)
## Summary

- ship **Buzz Term** end to end: the terminal engine/runtime, mounted
desktop substrate, and user-visible naming
- add Quinn's tape-deck-inspired banner: a beveled chassis filled by the
`buzz term` wordmark, surrounded by a complete-hex field
- derive the wordmark's three-stop sweep from each theme's terminal
palette so primary, secondary, and accent roles remain visibly distinct
across all 62 shipped themes, including light themes
- paint the banner once on its own pointer-transparent canvas; PTY
rendering beneath it remains unchanged

## Banner behavior

- uses the renderer's shared `8.4 × 17` cell metrics and production
aspect ratio `2.0238`
- regenerates only for viewport/theme changes; palette switches repaint
correctly while the banner is visible
- dismisses on non-empty output from the active terminal session; empty
output and inactive sessions do not dismiss it
- fails closed below **70 columns** rather than squeezing or clipping
the wordmark
- adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and
**zero lines inside `paint()`**

## Screenshots

| Buzz (light) | Buzz Dark |
|---|---|
| ![Buzz Term — Buzz
light](https://buzz.block.builderlab.xyz/media/fe4c50c1cd03645bff8f3cff588353618fd3004f320ab360165b6cb48f041eb6.png)
| ![Buzz Term — Buzz
Dark](https://buzz.block.builderlab.xyz/media/e5731f40fe020070c0b327287b4b0b6f0103adde852d0f6451ad66a0ff9d14bb.png)
|

| Kanagawa Lotus (light) | Red |
|---|---|
| ![Buzz Term — Kanagawa
Lotus](https://buzz.block.builderlab.xyz/media/260b211fe7bd232eb7faa4ec34c99baee5fed380556427c64495ceed3396a8c5.png)
| ![Buzz Term —
Red](https://buzz.block.builderlab.xyz/media/42a90830824683a0054e24649c58564134af831607736d823992181b792087d3.png)
|

Additional production-aspect finals:
[Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png),
[Min
Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png),
and [Dark
Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png).

The screenshot harness was checked against the shipped painter at this
exact head: all **2,541 draw calls** matched on color, glyph, x, and y;
four deliberate divergence controls fired.

## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd`

- desktop tests: **3,946 / 3,946**
- TypeScript: clean
- checks: pass (two pre-existing informational `useTemplate` notices
only)
- integration/e2e: PASS (independent exact-SHA lane; artifacts recorded
in the originating Buzz thread)
- artifact/dead-path sweep: clean
- redteam G1–G7: PASS
  - all six named banner emitter-deletion mutants die
- independent handwritten five-row full-wordmark fixture kills Quinn's
seven-mutant battery, including a one-pixel glyph change
- real `112 × 46` canvas-rect dismissal tests separately cover active
non-empty, active empty, and inactive non-empty output
- layer-drop and zero-draw painter mutants die; z-order and
pointer-events verified
  - CI's `tsc && vite build` includes all three banner modules
- performance at DPR 2 (worst-case measured envelope):
- one-time content paint: **~0.7–0.8 ms**, paid only when the banner is
built or its palette changes
- busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497
µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame)
- busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`:
**1,139–1,212 µs/frame** (**6.83–7.27%**)
- empty, one-glyph, and full-banner controls converge: compositor cost
follows backing-layer area and DPR rather than painted-cell count
- in the actual idle welcome state, cost is below both vsync-clamped
rigs' resolution; it is not claimed as zero
- **Pane cross-rig spread: resolved at matched loop rate.** Two
independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing
store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of
*that* spread is rAF loop rate: the higher figure came from a
free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read
58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures
quoted above remain the **unthrottled worst case** and are conservative
by ~2.3× at the pane. Not established: the mechanism and sign of
free-running distortion (one rig under-charges ~15%, the other
over-charges 2.3×), and the 1080p figure has not been re-measured
throttled.
- the layer paints only on generation/theme/resize and dismisses on
first non-empty active-session output, so the measurable busy cost is a
short-lived worst case rather than a persistent PTY paint-path tax

## Follow-ups in this PR

These are intentionally subsequent commits after the certified
static-banner head, not claims about `98ebc8f90`:

1. close the compositor metrology: remeasure the 1080p point throttled
and characterize the opposite-sign free-running rAF distortion, with
each measurement regime stated
2. add Tyler's animated honeycomb color waves, gated by
`prefers-reduced-motion`, a full 62-theme phase-sweep contrast check,
and DPR-2 per-tick performance certification
3. land the already-proven mounted theme-switch regression probe from
`RESEARCH/BUZZ_TERM_G3A_PROBE/`
4. bound the slow/hang-shaped G1-c mutant `waitFor`
5. optionally trim the generator to its ink bounding box, reducing the
minimum viewport from 70 to 62 columns

---------

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-03 21:51:17 -04:00
b29c8cdaa4 feat(desktop): redesign the Huddle experience (#4281)
## Summary

- open Huddles in a focused companion window with a clean handoff back
to the in-app drawer and backing channel
- redesign the participant film strip, sidebar control, transcript
surface, and themed shell treatment
- preserve microphone and device control across windows, start agent
voice on the first reply, and show agent speaking activity in the film
strip
- give each agent a distinct session voice, beginning with the
configured default, plus compact per-agent text-to-speech and voice
controls
- enroll only agents explicitly mentioned or deliberately added through
an agent panel into the live Huddle roster
- keep temporary Huddle channels out of the sidebar unless the user
explicitly brings one into the main app
- remove Huddle-only avatar policy badges and filter short silence or
noise segments before speech-to-text posts

## Why

The previous flow exposed the temporary channel as product UI, obscured
who was present or speaking, and split transcript and audio state
between the main and companion windows. This keeps backing channels as
implementation details unless a user explicitly brings a Huddle into the
app, while sharing the live conversation and audio lifecycle across both
surfaces. Agent participants now join only after an explicit invitation,
distinct voices make multi-agent Huddles easier to follow, and short
microphone noise no longer becomes stray transcript messages.

## Validation

- `pnpm check`
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/huddle-transcription.spec.ts
--project=smoke` (13 passed)
- Huddle sidebar visibility unit coverage (4 passed)
- focused managed-agent and persona-mention E2E coverage (2 passed)
- `pnpm test` (3,910 passed)
- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml
--all-targets -- -D warnings`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml` (2,093
passed, 14 ignored; 3 diagnostics passed)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-03 17:16:09 -07:00
985cdcc6ea feat(agents): model-tuning parity in global Agent Defaults editor (#4578)
## Overview

The global Agent Defaults surface (Settings card, defaults modal,
onboarding) exposed structured controls for Effort but left Max Output
Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs
had structured numeric fields but only for `isBuzzAgentRuntime` —
incorrectly excluding Goose. This PR unifies numeric-tuning capability
across all surfaces, fixes a pre-existing dual-editor defect, and adds
full test coverage.

## What changed

### Phase 1 — Catalog projection

- Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs`
(`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere).
- Project all three numeric env-var fields (`max_tokens_env_var`,
`context_limit_env_var`, `max_rounds_env_var`) end-to-end:
`AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`,
`RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in
`tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`).

### Phase 2 — Field model

- `deriveAgentConfigFieldModel` now derives `maxOutputTokens` /
`contextLimit` / `maxRounds` descriptors from catalog-projected fields.
- `structuredEnvKeys(descriptors)` — exported helper that takes the
**rendered** descriptor set (not the whole model). Hidden keys follow
what is actually rendered per surface: global hides effort + all three
numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides
effort + three numeric keys; per-agent Goose hides only its two numeric
keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row
per-agent because no effort control renders there.

### Phase 3 — UI

- Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as
a shared descriptor-driven component (`descriptors`, `envVars`,
`inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima:
`NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1,
`maxRounds`: 0) applied to `<input min>`.
- **Global surface** (`AgentConfigFields.tsx`): deduplicate the
previously duplicated Advanced env-editor block; render
`NumericTuningFields` below the env editor when descriptors exist;
`hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys`
so structured keys are never double-rendered. Under 1000 lines.
- **Per-agent surfaces** (`EditAgentAdvancedFields`,
`PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the
numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from
`agentConfigCore`; hidden keys come from
`structuredEnvKeys(numericDescriptors)` — the same rendered descriptor
set, no local rebuilding (fixes pre-existing dual-editor defect).
Catalog status carried as `RuntimeCatalogStatus` (`loading | ready |
error`); both error and loading withhold structured controls and leave
saved values visible as generic rows, making error distinguishable from
"runtime not capable" (`ready` + no runtime).
- **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`,
callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?:
"loading" | "ready" | "error"` (replaces separate
`runtimesLoading`/`runtimesError` booleans); all call sites —
`AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`,
`UserProfilePersonaDialogs` — compute and pass the status.

### Phase 4 — Tests

- `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows,
value, requiredKeys, hiddenKeys) => Record<string, string>` helper for
isolation testing.
- **17 new node tests** in `agentConfigCore.test.mjs`:
`deriveNumericDescriptors` (all three fields, partial, undefined
runtime, matches field-model subset); `structuredEnvKeys` per surface
including discriminating Goose per-agent effort-key invariant;
`NUMERIC_KIND_MIN` values.
- **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key
preserved through generic row edits; runtime-switch then generic edit
(derives both descriptor sets, asserts new-runtime hidden key survives
`buildRecord` via `hiddenKeys` and old-runtime key survives via generic
rows); baked numeric key excluded via `filterBakedGenericRows` with
`numericTuningPlaceholder` assertion; clearing a structured override —
`numericTuningPlaceholder` verifies placeholder text.
- **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to
smoke project `testMatch`): global numeric fields visible for
buzz-agent; global: non-capable runtime hides numeric controls; Goose
per-agent shows `Inherit (16384)` after saving global value through the
UI; delayed catalog: saved values visible as generic rows while loading
then structured controls appear after settle; failed catalog: saved
values remain visible as generic rows (never the "unsupported" empty
state).

## Result

- buzz-agent global defaults: Max output tokens, Context limit, Max
rounds as structured inputs with `Inherit (N)` placeholders from baked
env.
- Goose global defaults: Max output tokens, Context limit as structured
inputs.
- A Goose global value surfaces as `Inherit (<value>)` in the per-agent
Goose edit dialog.
- No structured key is editable in two places on any surface; no
persisted key has zero editors.
- No `runtime.id === "buzz-agent"` comparison decides numeric-field
visibility anywhere — capability flows catalog →
`AcpRuntimeCatalogEntry` → field model → UI.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-03 18:09:24 -04:00
klopez4212andGitHub 027a74a61c Polish Share Compute settings (#3735)
## Summary
- Refresh Share Compute with the shared agent-style model controls.
- Reveal sharing details and advanced options only while sharing.
- Remove the preview-only mesh API path.

## Validation
- `pnpm check`
- `pnpm test`
- `pnpm exec playwright test tests/e2e/mesh-compute.spec.ts`

Snapshots are attached in a follow-up comment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-03 14:12:21 -07:00
7981597848 fix(reactions): wrap long popover names (#3834)
**Category:** fix
**User Impact:** Long custom emoji names now stay contained inside
reaction popovers and remain fully readable.

**Problem:** An unbroken custom emoji name could force a reaction
popover beyond its intended maximum width and overflow the message view.

**Solution:** Give the reaction popover a definite 288px width and allow
the complete emoji name to wrap within it without truncation or
ellipsis. Short names retain the same content and interaction behavior.

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

**desktop/src/features/messages/ui/MessageReactions.tsx**
Bounds the reaction popover width and allows long names to break across
lines while preserving the full shortcode.

**desktop/tests/e2e/reaction-names.spec.ts**
Covers fixed width, full text preservation, and wrapping for the maximum
supported colon-wrapped reaction name, with deterministic seeded Picsum
visual fixtures and explicit image-load waits.

</details>

## Reproduction Steps

1. Open a message with a custom emoji reaction whose name is 64
characters.
2. Hover or focus the reaction pill to open its details popover.
3. Confirm the popover remains 288px wide and the complete name wraps
within it without ellipsis.
4. Open a short-name reaction and confirm its popover remains readable
and unchanged in behavior.

## Screenshots

| Before | After |
| --- | --- |
| ![Maximum-length name
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-before-picsum.png)
| ![Maximum-length name
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-after-picsum.png)
|

**Short-name regression check**

![Short reaction
name](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/short-name-after-picsum.png)

## Verification

- `pnpm test` in `desktop`: 3,858 passed
- Focused reaction-name E2E with seeded Picsum captures: 2 passed
- Desktop checks and commit hooks passed

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-03 14:09:46 -07:00
5c98932c59 feat(desktop): make onboarding model defaults skippable (#3968)
**Category:** improvement
**User Impact:** Users can skip default model configuration during
onboarding and finish it later in Settings → Agents.

**Problem:** Requiring model defaults during onboarding can block users
who are not ready to choose a harness, provider, or model. Skipping also
needs to leave existing configuration untouched rather than persisting
partial selections.

**Solution:** Stage onboarding edits locally and persist them only when
users choose Next or Back. A delayed Skip action advances without any
configuration write, while a footer hint points users to the settings
location for completing setup later.

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

**desktop/src/features/onboarding/ui/DefaultConfigStep.tsx**
Adds the skip action and future-settings hint, and makes model
configuration transactional so Skip discards staged changes while Next
and Back preserve the intended save behavior.

**desktop/src/testing/e2eBridge.ts**
Exposes model-config setter call counts so tests can distinguish a true
zero-write skip from a write-and-rollback implementation.

**desktop/tests/e2e/onboarding-agent-defaults.spec.ts**
Covers skipping during loading and after staged edits, verifies zero
persistence calls, and confirms Next and Back still commit changes.

</details>

## Reproduction steps

1. Start fresh onboarding and continue through harness setup to
**Configure your default model settings**.
2. Change the selected harness or model, then choose **Skip for now**.
3. Confirm onboarding advances to **Join or create a community** and the
prior global model configuration remains unchanged.
4. Return through onboarding and confirm **Next** saves the staged
selection; confirm **Back** also preserves staged changes before
returning.
5. Confirm the footer says model defaults can be configured later in
**Settings → Agents**.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-08-03 13:05:47 -07:00
c1b88af8d7 feat(desktop): improve channel template discovery (#4549)
## Summary

- move **Channel templates** from Communities to Personal settings
- always expose the template picker in New Channel, using **None** as
the no-template value
- create a channel template directly from the picker and select it on
return
- preview the selected template's current visibility, canvas, agents,
and teams
- order the channel-creation controls as **Type / Visibility /
Template** and mark Template **Optional**
- cover populated and empty libraries, inline creation, selection,
visibility overrides, mixed agent/team inventory, field order, optional
labeling, and settings navigation in Playwright

## Validation

Validated at desktop-only tip `76442270c88aa1d533ddca5de9f87cd615183919`
with a clean worktree:

- focused channel-template Playwright: 2/2 passed
- Type / Visibility / Template ordering and muted Optional treatment
visually inspected in the replacement screenshot
- `git diff --check origin/main...HEAD` passed
- PR diff contains exactly nine Desktop files and no Mobile files

The pre-push hook was bypassed only for the corrected history push
because the inherited Mobile test `keeps follow mode off while a tall
newest message stays visible` passes in Linux CI but fails on macOS
because its offscreen-child mounting assertion is platform-sensitive. No
Mobile code or tests are changed by this PR.

## Screenshot

![New Channel with Type, Visibility, and optional
Template](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4549/create-channel-type-visibility-template.png)

Originating Buzz channel: `efba7343-e147-48b7-a2aa-15a5f04abc57`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-03 09:30:15 -07:00
klopez4212andGitHub b0c6d6f744 Add channel activity hover menu (#3935)
## Summary
- show relevant unread threads and active agents when hovering a channel
- keep channel-level unread emphasis separate from thread activity dots
- make activity rows navigate to the thread and remove demo-only data

## Test plan
- `just ci` (all stages passed except the final duplicate native check,
which ran out of disk after its earlier clippy pass)
- `cd desktop && pnpm exec playwright test
tests/e2e/channel-activity-popover.spec.ts --project=smoke`

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-03 09:26:06 -07:00
f865c0054b feat(desktop): show saved Run on settings when editing an agent (#4539)
## What

When editing an agent, show where it runs. The edit dialog previously
showed nothing about the backend; the "Where to run" section only
existed in the create flow. This adds a read-only **Run on** section to
`AgentInstanceEditDialog`:

- **Local agents:** "This computer".
- **Provider agents (e.g. Kubernetes):** the provider id plus its saved
config rows — context, namespace, image, resources, etc. — with labels
humanized from the stored keys and rows in provider-schema order
(locators first, request/limit pairs adjacent, alphabetical spillover
for unknown providers).
- Copy states these are the settings **saved at creation** and that the
run location can't be changed afterwards (a new agent is required).

## Design decisions (from thread review with @Wren + @Sami)

- **No provider probe on edit.** `info` is executable work, and its
schema reflects the plugin *today* (including a freshly generated random
namespace default) — not what this agent was deployed with. The stored
record is the only honest source.
- **Saved settings, not effective settings.** Optional fields a record
omits (e.g. `service_account`) are defaulted by the provider at deploy
time; we render only what was persisted and never synthesize today's
defaults.
- **Safe rendering of opaque provider config.** Values render as safe
scalars only; arrays/objects degrade to a summary row (React throws on
object children — a hand-edited record must not crash the dialog).
Falsy-but-present values (`0`, `false`) render honestly. Secret-shaped
keys are redacted using the same word-split heuristic as the create-time
`validate_provider_config` gate — one definition of "looks like a
secret". The gate already blocks such keys on every app write path;
display-side redaction is screenshot hygiene and covers hand-edited
records.
- **`backendAgentId` intentionally excluded:** deploy-time runtime state
written on start, not saved creation intent.
- **Read-only, no form state.** The backend is immutable post-create
(`UpdateManagedAgentRequest` has no backend field), so the section
renders straight from `agent.backend` with no reset effect.
- `ADVANCED_FIELDS_MOTION_TRANSITION` was duplicated in both agent
dialogs; hoisted to `agentConfigOptions` (also keeps the edit dialog
inside the file-size ratchet).

## Testing

- Unit contract for `summarizeRunOn` (9 tests): scalar honesty incl.
`0`/`false`, structured-value fallback, secret redaction fail-safe,
preferred ordering with spillover, key humanization.
- Playwright spec (4 tests, registered in the smoke project): kubernetes
agent with the exact eight-key record a real create flow persisted,
local agent, blox agent (`workstation_name`), and redacted secret-shaped
keys from a hypothetical future provider.
- `pnpm typecheck`, `pnpm check`, full `pnpm test` (3937 pass) green at
this head.
- Live screenshots posted in the originating Buzz thread.

---------

Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-03 12:08:29 -04:00
5e0efb0bb9 fix(desktop): disambiguate provider API key labels and annotate mint key (#4406)
Two different credentials were presented under the same name throughout
the app. The top-level credential field for non-Anthropic providers
(OpenAI, OpenAI-compatible, OpenRouter) was labeled "OpenAI API Key" via
a hardcoded binary ternary repeated in three dialogs. The card-minting
key (`OPENAI_API_KEY`) and the runtime credential
(`OPENAI_COMPAT_API_KEY`) have independent endpoint namespaces and
consumers (`OPENAI_COMPAT_BASE_URL`/`OPENAI_COMPAT_API_KEY` for runtime,
`OPENAI_BASE_URL`/`OPENAI_API_KEY` for minting) and must remain separate
— either may require a different credential. This PR makes them
impossible to confuse in the UI.

## Changes

**Provider-accurate labels from the credential table.**
`PROVIDER_CREDENTIAL_CONFIG` entries now carry an `apiKeyLabel` paired
with `secretEnvVar` as a discriminated union (both present or neither —
a future provider cannot ship a secret field with no label).
`getProviderApiKeyLabel(providerId)` is the single source of truth. The
three hardcoded ternaries in `AgentConfigFields`,
`AgentInstanceEditDialog`, and `AgentDefinitionDialog` are replaced by
this helper. Labels: `openai` → "OpenAI Runtime API Key",
`openai-compat` → "OpenAI-compatible Runtime API Key", `openrouter` →
"OpenRouter API Key" (was incorrectly "OpenAI API Key"), `anthropic` →
"Anthropic API Key" (unchanged).

**Field names its backing env var.** `PersonaProviderApiKeyField`
renders the env var name as a monospace hint beneath the label with
`aria-describedby` wiring. All three call sites pass their
`secretEnvVar`. A user who sees `OPENAI_API_KEY` in the mint dialog can
now confirm at a glance that the credential field shows
`OPENAI_COMPAT_API_KEY` — a different key.

**Signpost visible at the decision point.** `CARD_MINT_KEY_ANNOTATIONS`
is exported from `agentConfigOptions.tsx` (single source) and passed as
`keyAnnotations` to all three generic env editors: both `EnvVarsEditor`
branches in Agent Defaults, `EditAgentAdvancedFields`, and
`PersonaAdvancedFields`. `CardMintKeyCue` — a new small component —
renders an always-visible muted cue beneath the Advanced toggle when
`OPENAI_API_KEY` is present in global env (Advanced is collapsed by
default, so the per-row annotation is invisible until the cue guides the
user to open it).

**Model discovery error copy.** The `OPENAI_COMPAT_API_KEY required`
message now reads "Enter an OpenAI runtime API key
(OPENAI_COMPAT_API_KEY) to load OpenAI models." — naming the env var
explicitly so it cannot be confused with the mint key.

## Tests

- `getProviderApiKeyLabel` helper: pinned correct label per provider
including the new distinct labels for `openai` and `openai-compat`
- `PersonaProviderApiKeyField` render: semantic label present; env-var
hint rendered when `envVarName` provided; `aria-describedby` wired to
hint id; hint and describedby absent when prop omitted
- `EnvVarsEditor` render: annotation appears exactly once on the
matching row; absent for non-matching rows
- `personaModelDiscoveryStatus`: pinned new copy naming
`OPENAI_COMPAT_API_KEY` explicitly
- Playwright: stale `"OpenAI API Key"` selectors updated; new
`card-mint-key-cue-visible-and-annotation-in-advanced` test covers
Will's exact path (databricks_v2 global provider + saved
`OPENAI_API_KEY` → cue visible before opening Advanced → annotation
present after opening)

## File sizes (post-format)

| File | Lines |
|------|-------|
| `AgentConfigFields.tsx` | 994 (≤ 996) |
| `AgentInstanceEditDialog.tsx` | 1228 (≤ 1228) |
| `AgentDefinitionDialog.tsx` | 1045 (≤ 1047) |

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz>
2026-08-03 11:12:34 -04:00
2c0ac24674 fix(desktop): stop the create-agent provider config probe from erasing keystrokes (#4411)
## What

Fixes the create-agent dialog's "Run on" provider config fields eating
keystrokes — reported by Tyler in buzz-remote-agents (channel
`29414326`, thread `db76677a`): the Kubernetes **Kubeconfig context**
field would not accept typing.

## Why it happened (the Typewriter Eraser, shipped in #4289)

`WhereToRunSection`'s probe `useEffect` depended on the whole `draft`:

1. every keystroke changed the draft → effect re-fired → provider binary
re-probed;
2. each probe result is a fresh object written into the draft → the
effect re-triggered **itself**, respawning the provider binary in a loop
for as long as the dialog sat on a provider;
3. every probe resolution reset `providerConfig` to schema defaults —
erasing whatever was typed. A field with no schema default (`context`)
snapped back to empty, i.e. "won't let me type". Unrelated to how many
kubeconfig contexts you have.

## Fix

- **Probe once per provider selection**, keyed on the provider's stable
`binaryPath` — not the draft, not the provider object (a
`useBackendProvidersQuery` refresh must not reprobe an unchanged
selection).
- **Latest-state resolution** via `React.useEffectEvent` + a new pure
`applyProbeResult` helper: schema defaults merge **beneath** the current
`providerConfig`, so a probe landing after the user typed can never
clobber in-flight input (per Wren's pre-patch red-team: changing deps
alone leaves a stale closure).

Existing `cancelled` cleanup keeps provider-switch/unmount safe;
selection reset (`emptyWhereToRunDraft`) and the fail-closed probe-error
path are unchanged.

## Tests

- **Unit** (`whereToRunIntent.test.mjs`): `applyProbeResult` merge
semantics — defaults under typed values, user-cleared fields stay
cleared, schema-less results, unrelated fields preserved.
- **E2E** (new `where-to-run-config.spec.ts`, added to the smoke
project, **red-first verified**: all 3 fail against the unfixed
component):
- typing into a defaultless provider field sticks, and
`probe_backend_provider` fires exactly once per selection;
- the config form is gated on probe resolution (slow probe: no
half-rendered form, defaults prefill once);
  - provider → local → provider re-probes and resets cleanly.
- Mock bridge gains `backendProviders` / `backendProviderProbeResult` /
`backendProviderProbeDelayMs` seams (defaults preserve prior behavior).

## Verification at 8eb7680

- `pnpm check` + `tsc` clean, `pnpm test` 3926/3926;
- new spec 3/3 green (and 3/3 red on the unfixed component);
- pre-push lefthook: desktop-test, desktop-check, desktop-tauri-checks,
rust-tests, mobile-test all green.

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-03 07:36:13 -04:00
f86cfc7369 fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (#3778)
## Problem

Two related gaps in global back/forward navigation. Fixes #3775.

1. The keyboard shortcuts almost never fire in real use — users fall
back to clicking the toolbar chevrons and assume the shortcuts don't
exist.
2. On macOS, mouse back/forward buttons (X1/X2) and horizontal swipe
gestures do nothing, although they navigate in every browser and in
Slack.

**Duplicate check:** searched open PRs and issues — none found beyond
#3775 (filed alongside this fix). #3078 / #3377 are
next/previous-*channel* navigation, a different feature.

## Root causes

**Keyboard:** `useBackForwardControls`'s keydown handler bailed whenever
the event target was editable — but `useComposerAutofocus` deliberately
focuses the message composer (a ProseMirror contenteditable) on mount
and on every channel switch. In steady state focus almost always lives
in the composer, so the chords were silently swallowed. Invisible to CI
because `navigation.spec.ts` only ever clicked the `global-back` /
`global-forward` buttons, never pressed the keys.

**Mouse/swipe:** on macOS, WKWebView never delivers X1/X2 button events
or swipe gestures to the page (Safari handles them natively in the app
layer, not in page JS), and Buzz had no native handler.

## Fix

### Keyboard chords (web layer)

Match the existing platform chord regardless of the event target and
drop the editable-target guard:

- `⌘[` / `⌘]` have no text-editing semantics in macOS text fields, and
the TipTap/StarterKit editor config binds no `Mod-[` / `Mod-]` shortcuts
(checked `useRichTextEditor.ts` — list indentation is Tab/Shift-Tab).
- `preventDefault()` keeps the chord out of the editor — asserted in the
e2e test.

This matches browsers and Slack, where back/forward chords work while a
text field is focused. Chord matching is extracted into a pure helper,
`app/navigation/backForwardChords.ts`, so it can be unit tested;
behavior (bindings, modifier exclusivity, `code`-based matching for
non-US layouts) is unchanged.

### macOS mouse buttons and swipe gestures (native layer)

An NSEvent local monitor in `mouse_nav.rs` catches what the webview
can't see and emits a `mouse-nav` Tauri event to the main window
(`emit_to`, so navigation stays scoped if multi-window ever lands) that
the frontend acts on. Two AppKit event shapes map to navigation:

- `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons arrive as
plain button events. These are swallowed after emitting so nothing
downstream double-handles them.
- `swipe` with a horizontal delta — AppKit's page-swipe gesture
(`swipeWithEvent:`): `deltaX > 0` back, `deltaX < 0` forward. Sent by
mouse drivers that synthesize a page-swipe gesture for the back/forward
buttons instead of button-3/4 events (the hardware this was verified
on). Stock Apple trackpad and Magic Mouse swipes arrive as phased
scroll-wheel events instead, which this PR does not handle — that path
(`ScrollWheel` + `trackSwipeEventWithOptions:`, which also needs
scroll-edge detection) is deferred to a follow-up. Swipes are passed
through (swallowing mid-gesture events could confuse AppKit gesture
tracking).

The swipe path was verified end to end on hardware whose back/forward
buttons emit only swipe gestures, never button-3/4 events — an
instrumented event monitor confirmed the events arrive as
`NSEventType::Swipe` with `deltaX ±1`, and navigation worked after
mapping them.

## Tests

- **13 unit tests** for the web-side chord matcher
(`backForwardChords.test.mjs`): supported chords, modifier exclusivity,
`code` fallback, and preservation of line-editing shortcuts.
- **6 Rust unit tests** for the native mapping helpers (`mouse_nav.rs`):
button 3/4 directions, other buttons ignored, swipe delta sign →
direction, zero-delta (gesture-begin) ignored.
- **e2e regression case** in `navigation.spec.ts`: presses the platform
chord *while the composer is focused* — the missing coverage. Verified
it fails against the pre-fix implementation and passes with the fix.
- Full desktop unit suite: 3832/3832 pass. Full Rust suite (`cargo
test`, buzz-desktop): 1888 passed / 0 failed. `pnpm typecheck`, `biome
check`, `pnpm check`, `cargo fmt --check`, `cargo clippy`: clean (no new
warnings).
- Full Playwright e2e: 958 passed; 6 failures are relay-infrastructure
tests (live relay seeding / relay state seam) that fail identically
without this change — `navigation.spec.ts` is fully green.

## Manual test

1. Open a channel, then another (composer autofocuses on each switch).
2. `⌘[` — returns to the previous channel; `⌘]` — forward again. Typing
`[` / `]` in the composer inserts normally.
3. Mouse back/forward buttons navigate the same way, from anywhere in
the window (verified on macOS on hardware using both event shapes).

## Update — 2026-07-31

Removed the redundant DOM mouse-button handler after verifying it was
unnecessary. The native macOS path remains unchanged and was revalidated
manually.

---------

Signed-off-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Signed-off-by: Matheus Iser <matheusiser@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-02 14:53:01 -04:00
c104eecfb3 feat(desktop): import local Pocket voices (#3259)
## Context

Pocket TTS currently offers bundled reference voices. People also need a
local, private way to add a voice without sending audio to a cloud
service.

## Summary

Add a Pocket voice import flow to Voice settings. Buzz opens the native
file picker, decodes common audio formats in the reusable `buzz-voice`
crate, canonicalizes the selected audio, stores it under a
content-derived identity in app data, selects it, and lets the user
delete it later.

## Changes

- Accept WAV, M4A, MP3, FLAC, OGG, and AIFF files between 2 and 30
seconds, including multichannel sources.
- Decode and downmix accepted audio to canonical mono 32 kHz PCM16 WAV
before hashing and storage.
- Store imported voices behind stable `pocket:imported:<sha256>`
identities and content-addressed files.
- Keep absolute file paths inside the native process and expose only
voice metadata to React.
- Include imported voices in Pocket preview and live huddle playback.
- Add Add voice and delete controls while preserving the bundled Pocket
voice catalog.
- Fall back to Mary when the selected imported voice is deleted.
- Keep durable import, selection, and deletion successful when a live
TTS worker acknowledgement is delayed.
- Preserve bundled voices when optional import metadata is unreadable
and keep failed deletion retryable.

## Related issue

None found.

## Testing

Production decoding was exercised with WAV, M4A with AAC, MP3, FLAC, OGG
Vorbis, and AIFF fixtures. Each format canonicalized to mono 32 kHz
PCM16 WAV. Manual validation in the combined daily-driver build covered
native-picker import, Preview, live-huddle playback, deletion, and Mary
fallback.

## Screenshots

The Voice settings card preserves the bundled Pocket catalog and adds
the local Add voice action.

![Pocket TTS voice
import](https://raw.githubusercontent.com/block/buzz/c03ba29060ca544c5ac3394c212f376651b386a3/pr-3259--pocket-voices.png)

## Reviewer-reproducible examples

Create common-format fixtures and run them through the production
importer:

```bash
. ./bin/activate-hermit
fixtures="$(mktemp -d)"
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=220:duration=3" -ac 2 -ar 44100 "$fixtures/voice.wav"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a aac "$fixtures/voice.m4a"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.mp3"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.flac"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a libvorbis "$fixtures/voice.ogg"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a pcm_s16be "$fixtures/voice.aiff"
BUZZ_VOICE_IMPORT_TEST_DIR="$fixtures" \
  cargo test -p buzz-voice imports_common_audio_format_fixtures -- --ignored --nocapture
```

Exercise import persistence, synthesis, deletion, and bundled-voice
fallback with an installed Pocket model:

```bash
BUZZ_POCKET_MODEL_DIR=/path/to/pocket-model-bundle \
  cargo test -p buzz-voice --test pocket_import_audio \
  objective_import_synthesis_delete_and_mary_fallback \
  -- --ignored --nocapture
```

Exercise the native-picker boundary, selection, preview dispatch,
deletion, cancellation, and invalid-file states:

```bash
cd desktop
pnpm build:e2e
pnpm exec playwright test tests/e2e/voice-settings.spec.ts --project=smoke
```

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 11:15:09 -04:00
39ce3dfc3c fix(desktop): open profiles from avatars (#3751)
## Summary
- show profile descriptions in hover cards as a single truncated line
- open the profile panel when avatars are clicked across desktop
surfaces
- make the direct-message intro avatar clickable

## Validation
- Desktop static checks
- 3,807 desktop tests via pre-push

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-31 14:24:51 +00:00
61ba9dfaa0 refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910)
Relands **#2467** (extract `buzz-voice` crate) and **#3208** (Pocket
voice settings) onto main, after #3266 and #3180 merged.

## Why a fresh PR
The repo is squash-only with delete-branch-on-merge. Squashing #3266
deleted `jtennant/pocket-tts-2026-04`, which was #2467's base — GitHub
auto-closed #2467 and it cannot be reopened. Squash merges also sever
ancestry, so GitHub's natural merge-base reports phantom conflicts for
the whole remaining stack.

## Content provenance
- Byte-identical to the blessed `jt/buzz-voice-refactor` branch
(`93029c577`, tree `6729e0eff` — reviewed by Dawn (#2467) and Max
(#3208) at exact heads) **except** the three files where #3180 and #3208
genuinely interact.
- Three-file resolution (union of both sides):
- `huddle/mod.rs` — #3180's pipeline re-exports + #3208's
`agent_tts_routing` imports.
- `huddle/state.rs` — `reset_preserving_generation` preserves both
`huddle_generation` (#3180) and `tts_enabled` (#3208); test sets merged
into one `tests` module.
- `desktop/src/testing/e2eBridge.ts` — both switch arms kept; no
duplicate case labels.

## Verification at cf32dacd7
- `cargo test` (desktop/src-tauri, pinned 1.95.0): **2047 + 3 pass / 0
fail** (14 ignored: 8 keychain, 4 real_relay, 2 flag-gated)
- `cargo clippy --all-targets -- -D warnings`: clean; `cargo fmt
--check`: clean
- `cargo check --workspace` (root, includes new `buzz-voice` member):
clean; `cargo test -p buzz-voice`: 5/0
- `pnpm test`: **3885 / 0**; `tsc --noEmit`: clean; lint: clean

The 3180×3208 interaction resolution is getting an independent team
re-review before merge.

Buzz channel: buzz-desktop-voice `fd5fb402-b651-4238-89b1-bb3e2fa4dc96`,
thread `b4798ecc`.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-07-31 09:41:09 -04:00
4632c55041 feat(desktop): auto-enable huddle transcription for agents (#3180)
## Context

Before this change, every huddle initialized with transcription off.
Joining or adding an agent did not enable it, so the agent could not
receive spoken conversation until a person clicked the transcript
control. Starting a huddle from an agent DM could also omit that agent,
and adding an agent who already belonged to the parent channel could
attempt an unnecessary role change and show a warning.

Agent detection uses authoritative huddle membership. A participant
counts as an agent when the ephemeral membership identifies it with the
`bot` role, or when the existing agent identity model identifies the
participant in an agent DM.

## Summary

Buzz now enables transcription once when the first authoritative agent
is present. After that initial automatic action, explicit user control
is authoritative: manual ON or OFF survives membership refreshes,
reconnects, and UI remounts. Removing the last agent does not change the
current transcription state.

Agent-DM huddles enroll the agent automatically. Adding an agent who
already belongs to the parent channel preserves the existing parent role
and completes without a role-mutation warning.

| Scenario | Before | With this change |
| --- | --- | --- |
| First authoritative agent joins or is hydrated | Transcription stays
off | Transcription turns on once |
| User explicitly turns transcription on or off | Manual control exists
without an agent policy | The explicit choice suppresses later automatic
changes |
| Last agent leaves | No defined agent-presence behavior | The current
transcription state remains unchanged |
| Huddle starts from an agent DM | The agent can be omitted | The known
agent is enrolled automatically |
| Added agent already belongs to the parent channel | Buzz can attempt a
role rewrite and warn | Existing parent membership and role are
preserved |
| Transcription is active | The control is not visually distinct | The
control is highlighted and exposes `aria-pressed=true` |

## Changes

- Derive agent presence from authoritative bot-role huddle membership
and known agent-DM identity.
- Apply the one-time auto-enable rule during create, join, membership
hydration, reconnect, pipeline startup, and local agent addition.
- Preserve explicit user state and use huddle-generation guards so stale
asynchronous work cannot alter a replacement huddle.
- Keep backend and React transcription state synchronized, with a
visible and accessible active control.
- Enroll known agent-DM participants and make parent-channel membership
updates idempotent.
- Cover hydration ordering, reconnects, remounts, explicit OFF,
last-agent removal, DM enrollment, existing membership, and active
styling.

## Related issue

None found.

## Testing

Manual validation in `pending-seed` confirmed the product contract:

1. Started a huddle from the owned, running Fizz agent DM.
2. Confirmed the authoritative roster contained the human and Fizz as an
agent.
3. Confirmed transcription enabled without clicking the control: `Stop
transcript`, `aria-pressed=true`, with the highlighted active
background.
4. Turned transcription off and confirmed `Start transcript`,
`aria-pressed=false` remained stable.
5. Removed Fizz while transcription was off and confirmed the state
stayed off.
6. Left the huddle cleanly.

## Screenshots

The same control has distinct active and inactive states.

![Active transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--active-transcription.png)

![Inactive transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--inactive-transcription.png)

## Reviewer-reproducible examples

From a fresh checkout:

```bash
pnpm --dir desktop build:e2e
pnpm --dir desktop exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke
pnpm --dir desktop exec playwright test tests/e2e/mentions.spec.ts --project=smoke --grep "system agent profile exposes owned agent actions|system agent avatar exposes owned agent actions|owned bot profile exposes message and huddle actions|owned agent mention profile exposes message and huddle actions"
```

The huddle scenario exercises initial authoritative hydration, exactly
one automatic enable, explicit OFF persistence, unchanged state after
last-agent removal, newer events winning over delayed hydration,
agent-DM enrollment, and idempotent parent membership. It also asserts
`aria-pressed` and distinct computed active styling.

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-07-31 07:06:41 -04:00
d88313f369 feat(desktop): delete a message by clearing its edit to empty (#3813)
## What

Clearing an edit to empty and hitting accept now **deletes the message**
instead of hanging. One of Sam's frequent workflows is to delete a
message by editing it, clearing the text, and pressing Enter — which
previously no-op'd (a deliberate guard blocked empty edits).

## How

Pure client-side wiring — **no relay, schema, or Rust changes.**

1. **`MessageComposer.tsx`** — the edit path had a guard that *blocked*
empty edits (`if (!trimmed && !hasMedia) return;`). That guard is simply
**removed**, so empty content flows through the normal edit path to
`onEditSave("", [], [])`. `buildOutgoingMessage("")` is a safe no-op.
2. **`handleEditSave` in `useChannelPaneHandlers.ts`** — when an edit is
submitted with empty text and no media tags, it exits edit mode and
opens the **same "Delete message?" confirmation** the Delete menu action
shows, rather than publishing an empty edit.
3. **`DeleteMessageConfirmDialog.tsx`** — the confirmation dialog,
extracted into **one shared component**. `MessageActionBar` renders it
for the Delete menu action (previously inline), and `ChannelScreen`
renders it for the empty-edit path. No duplicated dialog UI. **Delete**
runs the existing `deleteMutate`; **Cancel** leaves the message
untouched.

Because both the main timeline and the thread panel already route
edit-save through `handleEditSave`, this covers both surfaces with a
single dialog at the `ChannelScreen` level — no per-composer plumbing.

- Image-only edits (empty text but attachments present) still publish
normally — only a *fully* empty edit prompts to delete.
- An empty edit can never publish an empty body: `handleEditSave`
returns before the edit mutation.

## Review history

This PR was reworked three times in response to review — each pass made
it smaller:

1. First cut wrapped this in a new "Delete message?" `AlertDialog`
rendered from a composer hook — a verbatim duplicate of the confirmation
already in `MessageActionBar.tsx`. Removed.
2. Second cut threaded a dedicated `onDeleteEditTarget` callback down
`ChannelScreen → ChannelPane → MessageComposer / MessageThreadPanel`.
Also redundant — the delete decision moved entirely into
`handleEditSave`, which every edit-save already flows through.
3. Third cut added a special-case empty branch to the composer, which
pushed `MessageComposer.tsx` over the file-size ratchet and led to an
unrelated emoji-helper extraction to make room. Both gone: deleting the
pre-existing guard (rather than adding a branch) is net-negative, so
there's no ratchet pressure and **nothing emoji-related in this PR**.
`MessageComposer.types.ts` is back to baseline too.
4. Fourth pass (this one): an unconfirmed, no-undo delete was too sharp.
The empty-edit path now routes through the same **"Delete message?"
confirmation** as the menu action — shared as one
`DeleteMessageConfirmDialog` component (so it's reuse, not the duplicate
dialog from cut #1).

## Testing

- **E2E:** `desktop/tests/e2e/empty-edit-delete.spec.ts` (Playwright,
smoke project), three tests, all passing locally:
- *clearing an edit to empty prompts to delete, then deletes on confirm*
— edits the mock identity's own `#general` message, clears it, Enter →
the **"Delete message?"** dialog appears; Delete → the row disappears
and edit mode exits.
- *cancelling the empty-edit delete keeps the message* — same up to the
dialog, then Cancel → the message survives.
- *a non-empty edit still edits and never deletes* — guards the other
direction (no dialog).
- `pnpm typecheck`, biome, file-size + px-text guards all clean; full
desktop unit suite (3847 tests) passing locally.

> Heads-up for the reviewer: pushed with `--no-verify` because the
pre-push hook runs the Rust **integration** suite, which needs Docker
(Postgres/Redis) that isn't available in this environment — it doesn't
apply to this desktop-only change. CI runs the real gates.

---

🐝 Built by Bumble in Buzz, from a conversation in #test-swesterman.

---------

Signed-off-by: Sam Westerman <swesterman@squareup.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 22:34:12 -07:00
468647a51f feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
## Summary

Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to
the desktop app, per the plan reviewed in buzz-development (Rev 3,
approved 9/10 by Wren; implementation also reviewed and approved 9/10).

**Two-artifact design — canonical bytes originate entirely in Rust:**
- `create_ncryptsec_backup` runs under the `identity_mutation` lock:
encrypt → decrypt-verify against the live pubkey → atomic `0o600` write
to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return
the exact persisted bytes. The frontend never re-derives or re-encrypts.
- `save_ncryptsec_copy` writes a portable copy via the save dialog
(parse-gated, secret-file semantics) and never mutates canonical state.
- `generate_backup_passphrase`: 6 words from the EFF short wordlist via
`OsRng` (custom passphrases min 12 chars).
- Import accepts `ncryptsec1` with optional password; the raw-`nsec`
path is untouched. Different-pubkey import and sign-out wipe the
app-managed backup (post-commit, best-effort — a failed import can never
destroy the still-live identity's backup; regression-tested).

**Never-relay guarantee (egress guard + tripwires):**
- `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries
(relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters,
native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and
binary frames. Scope is deliberately ncryptsec-only: pairing
intentionally carries raw nsec inside its encrypted session.
- Site-granular `/events` inventory tripwire: per-file (`/events` count,
guard-call count) pairs; unlisted files expect zero. Mutation-style
tests prove a ninth site in an existing file, a removed guard, and a new
unlisted file all fail the scan.
- ncryptsec source-allowlist scans in **both** trees (Rust + TS).

**Frontend:** onboarding `BackupStep` is encrypted-by-default — the
default path never invokes `get_nsec` (e2e asserts the command log).
Raw-nsec export stays behind an explicit click with prior semantics.
Shared `EncryptedBackupCreator` powers onboarding + a new settings row;
the import form auto-switches to encrypted mode on `ncryptsec1` paste
(case-insensitive HRP).

**Open product call for @tlongwell-block:** onboarding default is
*encrypted* in this PR; flipping to raw-default is a small change either
way (documented in the plan).

Review history: plan Rev 3 and the implementation were both iterated
with Wren to 9/10 (two blockers from round 1 — import ordering,
inventory granularity — plus an uppercase-bech32 hardening gap, all
fixed in `dde37183e`). Thread: buzz-development.

### Related issue

Follow-up to the direction explored in #385 (NIP-PB, closed) — this
ships local NIP-49 (the standard) instead of a new NIP. No open
duplicate found.

### Testing

All at exactly `dde37183e` (same shell, HEAD verified):

- `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a
deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password,
NFKC, uppercase-vector decrypt, injection test per egress boundary,
inventory mutation tests, import-ordering regression tests)
- `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt
--check` — clean
- `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned
2.4.16) clean
- Playwright `onboarding-backup` / `onboarding` /
`onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known
avatar-reservation flake (passed on rerun; untouched by this diff).
`passThroughBackupStep` now exercises the encrypted default, so every
downstream onboarding spec covers the new path.
- Note: browser e2e fakes the crypto via the mock bridge (fixed
spec-vector blob); decryption correctness is proven in the Rust tests.

## Latest onboarding integration

The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 20:58:03 -04:00
9e8fcfda09 fix(desktop): channel topic and membership metadata cleanup (#3642)
First slice of #2216, scoped to the system/status lines in the chat
timeline.

## Why

Two problems on the same surface.

**Clearing a channel topic renders as empty quotes.** The relay reports
a clear as a `topic_changed` event carrying an empty string — there's no
separate "cleared" event type. So the timeline printed:

> Alice
> changed the topic to “”

which reads as if the topic were *set to* two quote marks. Same for
purpose.

**The membership caption reads like a headline, not a metadata line.**
`title` and `action` render on separate lines — the member's name sits
in the header row with the avatar and timestamp, and the caption sits
beneath it. So the caption was "was added by Alice Chen" standing alone
under a name, while its siblings on that same line are "joined the
channel" and "left the channel".

## What

- Blank, missing, or whitespace-only topic/purpose now reads **"cleared
the channel topic"** / **"cleared the channel purpose"**.
- Membership captions drop "was": **"added by Alice Chen"**, matching
"joined the channel" and "left the channel".
- The wording moves to `lib/systemEventCopy.ts` as a pure function, so
it's assertable in a unit test instead of only reachable through the
DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`,
taking it 911 → 900 lines.

## Two E2E assertions this exposed

Both were measuring something other than what they claimed, and the copy
change tipped them over. Neither is a product bug, but both would have
failed the next person too.

1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while
the mouse was still parked from a previous `hover()`. Any reflow — new
rows, scroll-to-bottom, a different text wrap — can slide that button
under the stationary pointer, so the assertion measured *where the mouse
happened to be* rather than the resting style. Dropping four characters
changed the text wrap, changed the row height, changed the scroll
offset, and the pointer landed on it. Now parks the pointer off-target
first.
2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once
the first tooltip animates out while the second opens, two elements
match and strict mode trips. Now scopes to the open tooltip via
`:not([data-state="closed"])`.

## Deliberately out of scope

- **Timestamps.** The day divider, per-message clock times, the Inbox
thread pane, and the inbox list have three divergent date
implementations and none fully match the writing standard's
Today/Yesterday/weekday/date progression. That's its own slice of #2216.
- **Whose avatar shows.** An addition puts the *added* member in the
header; a removal puts the *remover* there. Possibly intentional, but
it's a design question, not copy.
- **`the channel` vs `this channel`.** joined/left/removed say "the
channel"; created/archived/unarchived say "this channel". Worth
normalizing, but it touches lines this PR otherwise leaves alone.

## Validation

- `pnpm check`, `pnpm typecheck` — clean
- Unit: **3781/3781**, including 6 new tests in
`systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace
for both fields, plus a guard that no variant can emit empty quotes
- Smoke E2E `mentions` + `messaging`: **85/85**
- The previously fragile test run with `--repeat-each=5`: **5/5**

Signed-off-by: Clay Delk <clay.delk@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:53:04 -07:00
Bradley AxenandGitHub ede2686334 fix(desktop): align data deletion labels (#2230)
## Why

The Profile settings action still says “Sign Out,” while its
confirmation action says “Delete My Data.” Both buttons trigger the same
destructive local-data wipe and should name it consistently.

## What

- Label both destructive actions “Delete my data”
- Assert the matching section and confirmation labels in the existing
Playwright coverage

## Risk Assessment

Low — copy and test assertions only; sign-out behavior is unchanged.

## References

- Follow-up to #2208
- #2216 also touches this copy and should preserve “Delete my data” when
rebased
- `just desktop-check`
- `just desktop-test` (3,275 tests)
- Desktop E2E build and sign-out Playwright spec (2 tests)

Generated with Codex

Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-07-30 15:47:35 -07:00
74cd571219 fix(desktop): report authenticated relay recovery (#3812)
## Summary

- report the relay as connected immediately after socket open and
successful AUTH
- keep rate-limited subscription replay, the connect promise, and
reconnect listeners unchanged
- cover authenticated reconnect while replay is held behind the shared
rate-limit gate

## Why

After WARP recovery, the socket could reopen and authenticate
successfully while subscription replay waited behind the existing
rate-limit gate. `connect()` kept `ConnectionState` at `reconnecting`
during that intentional delay, so the desktop displayed “Can’t reach the
relay” despite authenticated traffic already flowing.

This is separate from #3774: that fix keeps routine operations from
bypassing scheduled reconnect backoff. This patch preserves those
protections and only corrects the authenticated transport-state
boundary.

## Failure semantics

If replay fails after the early `connected` transition, the existing
`replayLiveSubscriptions()` catch calls `resetConnection()`, closes the
socket, returns state to `reconnecting`, and schedules recovery.
Operation waiters and reconnect notifications still do not complete
until replay succeeds.

## Validation

At commit `c8a4308e1079f4f9e6a72f0f0bfba280fe822ec0` with a clean
working tree:

- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop test` — 3,847 passed
- `pnpm --dir desktop check` — passed; two pre-existing informational
template-literal notices
- `pnpm --dir desktop exec playwright test
tests/e2e/relay-reconnect.spec.ts` — 8 passed
- regression test proven red before the production ordering change
(`reconnecting` after 3 seconds) and green after it

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-30 22:04:14 +00:00
b9e4ed616f test(desktop): click visible thread collapse guide (#3800)
## Summary

- target the visible thread branch collapse guide in the messaging smoke
test
- avoid clicking the underlying collapse rail when the guide overlaps it
- retain the existing post-click assertions that verify the two-reply
branch collapses

## Context

`main` CI failed because Playwright repeatedly attempted to click the
lower `thread-collapse-rail` while the matching `thread-collapse-guide`
intercepted pointer events. Both controls dispatch collapse for the same
branch; the guide is the actual topmost user target and is already used
by `thread-unread.spec.ts`.

Failing run: https://github.com/block/buzz/actions/runs/30575425126

## Validation

- focused Playwright smoke test: 1 passed
- pre-push hooks: desktop check passed; 3,835 desktop tests passed
- `git diff --check`

## Review

Princess Donut reviewed the test-only approach and locator determinism
with no blockers. Mongo review is pending.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-30 14:50:51 -07:00
d40a33290e feat(desktop): raise the install ceiling and make installs observable (#3368)
Windows installs of Goose and other harnesses failed at exactly five
minutes with an empty error (#2401). The 300s ceiling was killing
installs that were working, just slowly — the Goose step pulls a ~79MB
release asset, and Windows Defender scans every file npm extracts. When
the ceiling fired it discarded the output it had already read, so the
user got a bare timeout string and no way to tell a hang from a large
download.

## The ceiling

`INSTALL_TIMEOUT` is 900s, and the error names the limit: `install
command exceeded the 15m ceiling and was terminated`. It stays a pure
wall-clock ceiling with no inactivity kill — nothing observable
distinguishes a hung installer from one silently transferring a large
artifact, so silence alone never kills an install. A ceiling kill
remains non-retryable; re-running a command that already burned 15
minutes costs the user more time with no plausible path to success.

The child's exit and both stream drains fold into one resumable settle
governed by a single deadline. Waiting on the drains outside that
deadline would let a descendant that outlived the install shell hold the
output pipes — and the per-runtime install guard behind them — open with
no bound, which is the failure the ceiling exists to prevent. So the
deadline path terminates the process group on the normal-exit branch
too: a leader that exited with a real status still gets its stragglers
killed, and the guard cannot stick either way. Whether the leader had
already exited only decides the verdict — its real status outranks a
timeout.

The install shell is a session leader and its descendants inherit the
output pipes, so signalling only the leader left them running and the
drains blocked on a pipe nobody would close. Escalation keys off the
*group's* liveness rather than the leader's, since a descendant that
ignores SIGTERM outlives the leader and would otherwise never receive
the group SIGKILL. Reaping the killed child and finishing the drains
share one bounded grace, so a termination that failed outright cannot
extend the ceiling that just fired.

## Output capture

Each stream drains into a bounded capture that is *shared* with the
reader rather than returned by it, so whatever arrived before a stall is
readable at the ceiling — exactly when the output matters most. Output
of any size costs a fixed amount of memory.

One capture holds two independently bounded views of the same bytes:

| View | Head / tail | Cut marker |
|------|-------------|------------|
| UI (`InstallStepResult`) | 512 B / 1024 B | `... (N bytes omitted)
...` |
| Log file | 128 KiB / 128 KiB | `... [N bytes omitted at cap] ...` |

The UI budget is screen space; the log's is disk. Both markers are
inline, so neither ever implies completeness it does not have. Both ends
are cut at arbitrary byte offsets, so a partial character is trimmed and
the partial token each cut left behind is dropped — the marker's byte
count includes both trims.

## Install log

`steps` carries only the last attempt of each step, truncated for
display. Everything else — earlier retries, the prerequisite step that
actually broke, the managed-Node bootstrap — used to be discarded.
`InstallReporter` now appends one self-contained record per attempt of
per step to `install-<runtime-id>.log` beside the agent logs, and
`InstallRuntimeResult.log_path` carries the file to the UI, where a
failure message ends with `Full log: <path>`.

Each record is bounded independently by the log-scale capture that
produced it, so a first attempt that printed megabytes cannot push out
the later record explaining the failure; the run's total is bounded by
steps × attempts × per-record cap. Every early return builds its result
through one `InstallReporter::failed` helper, so no failure path can
omit the log pointer, and synthesized steps go through `record_step` — a
step that reaches the UI without passing it would be invisible in the
file.

Install output can echo a registry token or proxy credential from the
environment it ran in, and the file is written unattended. Redaction
keys off the *names* of the environment variables the install inherited,
snapshotted once per run, rather than a list of known secret value
prefixes: a credential with no recognisable shape is exactly the one a
prefix match misses. Three name rules apply, because the variables need
different treatment:

| Rule | Variables | Redacted |
|------|-----------|----------|
| URL userinfo | `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`,
`NPM_CONFIG_PROXY`, `NPM_CONFIG_HTTPS_PROXY`, `NPM_CONFIG_REGISTRY` |
`user:password` only |
| Exact name | `NPM_CONFIG_KEY`, `NPM_CONFIG__AUTH`, `NPM_CONFIG_OTP` |
whole value |
| Marker substring | `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, `*_PAT`, … |
whole value, 8-byte floor |

A proxy or registry keeps its host and port, because an install that
fails behind one is diagnosable only if the record still says which one
it went through, and a bare `user@` with no password is not treated as a
credential. npm's own settings are listed by exact name rather than
matched on `KEY` or `AUTH` substrings — both occur throughout an
ordinary environment on values that are paths and people's names — and
they bypass the 8-byte floor, since a six-digit one-time password is a
credential at that length. Matching is case-insensitive, which is what
npm's lowercase `npm_config_*` spelling needs. `0o600` is set by the
create rather than a later `chmod`, which would leave a window where the
umask decides. A runtime id that cannot safely be a filename yields no
log rather than a sanitized one — a rewritten id could collide with
another runtime's log.

The file holds exactly one run. A run opens its own session after the
runtime id has been canonically resolved — the previous file rotates to
`.1` and any older `.1` is removed before the rename, since a rename
that will not replace its destination would otherwise wedge rotation
permanently on Windows. The session writes a header naming the runtime,
the app version (`app.package_info().version` on the Rust side — cannot
be mocked or fail), the OS (`std::env::consts::OS`), and the start time:
a Windows failure and a macOS one on the same runtime are different
bugs, and a stale app version explains a failure that no longer
reproduces. Each record carries its attempt's elapsed time.

## Live output line

A 15-minute ceiling with nothing behind it but a spinner is
indistinguishable from a hang. The same drain seam feeds an
`acp-install-output` event carrying the newest complete line, and the
three install entry points — Doctor harness rows, the harness catalog
dialog, and onboarding runtime cards — render it under the spinner with
`aria-live="polite"`.

Ordering is keyed on a `seq` monotonic across the whole install, not on
the attempt number, which restarts at 1 for every step: keyed on
attempt, one step succeeding on attempt 2 would make the next step's
attempt-1 output look stale and freeze the display for the rest of the
install. Each executed attempt begins with an unthrottled `line: null`
clear signal, so a stale failure line cannot sit under the spinner while
the retry runs. Events are otherwise throttled to four per second, and
the throttle *retains* the newest pending line and flushes it when the
window reopens rather than dropping it — at an attempt boundary a drop
would silently eat the new attempt's first line.

The subscription is mounted for the runtime's whole lifetime rather than
started when the install begins. The install command is invoked from the
click handler, so the clear and a fast command's first lines can be
emitted before React has committed the pending state, and nothing
replays them — a subscription that waited for that state would lose the
entire output of a short install. The run boundary resets the ordering
key when the install settles, since `seq` restarts for the next run, and
the line renders only while installing, so a straggler from a finishing
drain cannot appear under a fresh Install button.

The 15-minute ceiling deliberately stops waiting on stuck drain threads
— a hung installer must not freeze the app. That means a drain thread
can outlive its `InstallReporter`. Without a generation guard, a drain
that calls `offer` after the run settles would publish an event with the
run's high `seq`, poison the permanent listener's React state, and cause
the next install's restarted `seq=0` events to be rejected. `Live` now
carries a `lifecycle: Arc<RwLock<bool>>`; drain threads hold a **shared
read guard** from the admission check through the `(self.emit)(...)`
call, making the check-then-emit pair atomic with respect to shutdown.
`InstallReporter::drop` takes the **exclusive write guard** and stores
`false` — this blocks until every in-flight drain publication releases
its read guard, then prevents any new admission. Deactivation is
bounded: the write lock holds only for the flag store, so it can block
at most for the duration of one emit call (microseconds to low
milliseconds). Rust drops locals in reverse-declaration order, so
`reporter` drops before `_guard`, ensuring the exclusive write completes
before the per-runtime concurrency guard releases and a new install can
start.

## Also

Install result types move to `desktop/src/shared/api/installTypes.ts`,
following the existing `searchTypes.ts` / `workflowTypes.ts` convention,
and are re-exported from `tauri.ts` and `types.ts` — both already over
the file-size cap, so neither can grow to carry them.

Two comments described `AdapterOutdated` as applying only to the
deprecated package; it also covers a version below the supported floor.

Report: #2401

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-30 17:07:22 -04:00