Commit Graph

2509 Commits

Author SHA1 Message Date
ss-dev-00
91d4e93ff1 feat(desktop): pin the conversation transcript variant to the cover drawer
Resolves the `TODO(#6538)` the cover drawer landed with. The reading view
is now chosen by presentation instead of inferred from panel width: the
cover drawer is the reading surface, so it returns `conversation`, and
every other host stays on the dense activity feed.

Width was only ever a proxy, and a breakable one — the split pane can be
dragged wide and a narrow overlay can be tall. The presentation that
already decided to cover is the thing that knows this is a reading
surface, so it decides the variant too.

Non-cover hosts return `undefined` rather than `"default"`: the panel
already defaults, and naming it here would imply this function decides the
non-cover variant when the profile panel picks `compactPreview` at its own
call site.

Asserted end-to-end on the transcript's `data-transcript-variant` marker in
the screenshot spec rather than on the prop, so it proves the value
survives the whole path from the resolver through the panel to the
transcript. It lives in that spec because the marker only renders once the
transcript has content, and that is the spec that seeds a turn. Verified
discriminating: reverting the pinned value alone fails the assertion.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-25 14:18:36 -07:00
ss-dev-00
9631221e54 preview: merge the agent activity cover drawer (Slice A) onto the stack
Conflict in ChannelPane.tsx resolved to Slice A's side: main only dropped a
comment that A moved into ChannelAgentSessionSurface.tsx when it extracted
the surface, so A's extraction supersedes the inline block.
2026-08-25 14:18:31 -07:00
ss-dev-00
dacb33d2bc preview: merge tool-chain cards (Slice C, carries B + restyle) onto main
* commit '91a5754ad':
  fix(desktop): keep a reader's work block open when a later answer regroups it
  test(desktop): pin the plain next-turn gap and stop the 5-minute suite hang
  fix(desktop): guard the work-block pulse and stop a finished block going live
  test(desktop): split the work-block suite and prune the conversation harness
  fix(desktop): settle a work block whose running step has no live session
  feat(desktop): fold focus-mode agent work into one transcript block
  fix(desktop): hide the decorative identity avatar and split the conversation suite
  fix(desktop): resolve the focus identity row agent through the profiles lookup
  fix(desktop): apply the focus code recipe to fenced human prompts too
  style(desktop): bring the conversation variant closer to berd's recipes
  fix(desktop): never show a trigger title as the prompt author
  fix(desktop): pin the transcript baseline fixture's locale too
  fix(desktop): pin the transcript baseline fixture to UTC
  fix(desktop): address review on the conversation transcript variant
  feat(desktop): add conversation transcript variant for focus-mode agent sessions

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
2026-08-25 14:17:46 -07:00
Jordan Mecom
ee6ca5fa28 Remove public relay signing key fallback (#6729)
Require `BUZZ_RELAY_PRIVATE_KEY` on every relay startup and remove the
shared public fallback key completely.

For local development, `just bootstrap` now generates a random key once
in the gitignored `.env` file. Re-running bootstrap preserves that key,
so relay-authored events retain the same identity across restarts. The
local relay recipes reload `.env`, while isolated CI and E2E launchers
pass explicit per-run keys.

Startup fails immediately when the key is missing or invalid, before
connecting to Postgres or Redis. Deployed relays continue to receive
their stable key from the existing chart-managed secret.

Validated with:

- `./scripts/test-ensure-local-relay-key.sh`
- `cargo test -p buzz-relay --bin buzz-relay`
- `cargo clippy -p buzz-relay --bin buzz-relay -- -D warnings`
- `cargo fmt --all -- --check`
- `shellcheck scripts/ensure-local-relay-key.sh
scripts/test-ensure-local-relay-key.sh`

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-08-25 13:57:23 -07:00
Wes
22f32c99e2 docs(nest): make commit attribution policy-neutral (#6707)
## Summary

- replace the generated Nest's unconditional human author/sign-off rules
with portable guidance that separates authorship, material
co-authorship, DCO certification, and cryptographic signing
- defer attribution to repository-local policy, forbid inferred or
guessed identities, and require inspection of every outgoing commit
- bump the Nest template version so existing installations refresh, with
regression coverage for fresh generation and upgrade preservation

### Related issue

None found. Related runtime identity work exists in #6177, but this PR
is intentionally limited to the generated Nest guidance and its refresh
behavior.

### Testing

- `bin/just desktop-tauri-clippy`
- `bin/just desktop-tauri-test`
- `bin/just file-size-check`
- `git diff --check`
- pre-push hooks: `push-head-scope`, `branch-skew`, `file-size-check`,
and `desktop-tauri-checks`

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-25 14:35:53 -06:00
Taylor Ho
b58de7cfa7 fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793)
**Category:** fix
**User Impact:** Users can place `@Agent` mentions inline anywhere in a
message—even when that agent is also persistently addressed—so separate
agents can receive separate instructions in the same message.

**Problem:** Selecting an agent from the composer mention picker could
turn the selection into persistent addressing instead of leaving an
inline `@Agent` at the cursor. That made persistent addressing and
inline composition mutually exclusive: once the persistent behavior took
over, users lost the clear `@Agent A do X, @Agent B do Y` message
structure they previously had.

**Solution:** Make inline mentioning and persistent addressing
independent behaviors:

- Ordinary mention selections always insert `@Agent` inline at the
current cursor position.
- This remains true when the agent is already persistently addressed;
the persistent audience never blocks or replaces an inline mention.
- If **Automatically mention agents** is enabled, a successfully sent
inline mention may additionally make that agent persistent for later
messages. Existing saved preferences remain respected.
- The dedicated automatic-mention control and primary+Shift+Enter
shortcut continue to add or remove persistent addressing directly.
- For users without a saved preference, **Automatically mention agents**
now defaults off.

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


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs**
Updates preference coverage for the default-off behavior while
preserving explicit saved choices.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts**
Defaults automatic post-send persistence off when no valid preference
exists.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Verifies ordinary picker selections insert inline mentions without
changing or pulsing the persistent audience, including when the selected
agent is already persistently addressed.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**
Keeps ordinary mention selection on the existing inline insertion path
while preserving the separate persistent-address controls and shortcut.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Covers separately targeted inline instructions to two agents, signed
outgoing recipients, default-off persistence, and explicit opt-in
persistence scenarios.

</details>

## Reproduction steps

### Inline mentions without persistence

1. Open a channel with at least two available agents and leave
**Automatically mention agents** disabled.
2. Use the composer mention picker to insert the first agent, type an
instruction, then insert a second agent and type a different
instruction.
3. Confirm the draft reads like `@Agent A review this, @Agent B test
that` and neither agent appears in the persistent addressed-agent
controls.
4. Send the message and confirm both agents are recipients while neither
remains persistently addressed for the next draft.

### Inline mentions with persistence

1. Enable **Automatically mention agents**, then mention an agent inline
and send successfully.
2. Confirm that agent becomes persistently addressed for later messages.
3. In a new draft, select the same agent from the mention picker again.
4. Confirm a new inline `@Agent` is inserted at the cursor while the
agent remains persistently addressed.
5. Confirm the dedicated automatic-mention control and
primary+Shift+Enter shortcut can still add or remove persistent
addressing directly.

## Demo

- **Before:** Persistent addressing could consume an ordinary picker
selection, preventing users from placing that agent inline in the
message.
- **After:** Ordinary selection always produces an inline `@Agent`;
persistence is a separate optional behavior that can coexist with inline
mentions.

### Related issue

N/A — reported through the Buzz feature room.

### Testing

At `a4579c0a666f3644abe44271614186d7b9356bf5`:

- Post-push hooks passed desktop check, desktop typecheck, and the full
desktop unit suite.
- The two persistence-focused Playwright smoke scenarios passed after
explicitly opting into **Automatically mention agents**.
- The one-time multi-agent targeting and persistent-address shortcut
Playwright scenarios passed on the feature changes before the final
rebase; CI is validating the rebased branch.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-25 13:32:09 -07:00
Luke Tornquist
f249710331 Qualify canonical relay images for staged delivery (#6781)
## Why

Phase 1 of progressive delivery needs an explicit eligibility boundary
before deployment automation can safely consume relay artifacts. Today
the canonical Docker workflow can publish independently of the same-SHA
CI result, the relay does not expose its source/build identity, and the
Helm chart only renders mutable tags.

The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains
a manual pre-merge preview lane. It is deliberately not a canonical
promotion input.

## What changed

- Compile the full source SHA and GitHub Actions build identity into
canonical relay binaries and expose it at `/_status`.
- Gate creation of tagged multi-architecture manifests on the latest
exact-SHA `CI` push run succeeding on `main` or `release`.
- Preserve the signed SLSA provenance and add a signed, release-only
deployment-eligibility predicate with source, build run, qualifying CI
run, and exact compatible Buzz chart version.
- Keep failed or pending builds untagged and without an eligibility
predicate; preview-package artifacts are excluded by package and signer
contract.
- Add optional `image.digest` support to Buzz chart `0.1.8`, retaining
the existing tag fallback.
- Document verification and the intentional boundary between image/chart
compatibility and backwards-compatible database migrations.

## Risk and rollout

Canonical tag publication now waits for same-SHA CI and fails closed
when the newest matching run fails. Per-architecture blobs may remain
untagged after a failed qualification; they are not promotion inputs.
Debug manifests are also CI-gated but do not receive the release
eligibility predicate.

There are no migration or schema-orchestration changes. The companion
BuilderBot infrastructure PR will pin a separately verified canonical
main digest and expose a read-only running-deployment inspection
command. Signed eligibility is available only for artifacts published
after this workflow lands.

## Verification

- Blox: workflow contract fixtures, including stale-green rejection and
predicate structure
- Blox: `actionlint` for `ci.yml` and `docker.yml`
- Blox: 46 Helm unit tests, chart lint, and production/quickstart render
fixtures
- Blox: relay status unit test, workspace Rust formatting, and workspace
all-target clippy
- Live evidence: exact-SHA CI selector chose successful run
`32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671`
- Existing canonical digest provenance verified with `gh attestation
verify`

## References

- #6709 — developer-preview precursor; intentionally separate from
canonical eligibility
- `docs/deployment-identity.md`

*Generated with Codex*

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
chart-v0.1.8
2026-08-25 14:44:49 -04:00
Taylor Ho
7ba1197aa6 feat(desktop): persist agent addressing across composer messages (#6714)
**Category:** improvement
**User Impact:** Agent addressing now stays visible, editable, and
consistent across messages, so users can keep talking to the same agents
without rebuilding the audience for every send.

**Problem:** Agent addressing was transient and easy to lose track of.
Inline mention text, persistent audience state, restored drafts, and
sent-message rendering could drift apart, making it unclear who would
receive the next message.

**Solution:** The composer now treats addressed agents as a persistent
audience represented by ordinary editable mention chips and a
synchronized compact avatar tray. Eligible agent mentions can become
persistent immediately, addressing survives sends without flashing
empty, and removing or explicitly unpinning an agent updates that state
without disturbing unrelated draft text. `Cmd+Shift+M` / `Ctrl+Shift+M`
provides a fast way to toggle the current recipient or select the best
eligible agent.

- Promotes eligible inline agent mentions into persistent addressing
when the preference is enabled; human mentions remain unchanged.
- Keeps inline chips, restored drafts, persistent recipient state, and
the avatar tray synchronized.
- Clears an agent's persistent address when its final chip is removed,
while explicit unpinning preserves unrelated draft text.
- Restores persistent mentions optimistically during send and restores
the full draft on failure, avoiding an empty-composer flash.
- Deduplicates inline mentions and tag-backed address prefixes in sent
messages.
- Toggles the current addressed agent with `Cmd+Shift+M` /
`Ctrl+Shift+M`, or selects the best eligible agent when none is
addressed.
- Ranks shortcut selection by recent explicit stream mentions, then
active/member/runnable/stable fallbacks; DM participant fan-out does not
affect recency.
- Adds focused avatar feedback for entry/exit, send bounce, and failure
shake, with reduced-motion and narrow-layout support.
- Applies the same behavior to main and thread composers while
preserving draft restoration, channel switching, and send-race
correctness.

### Screenshots

#### Light theme
The compact tray keeps persistent recipients visible alongside editable
mention chips.

![Light theme persistent agent
addressing](https://raw.githubusercontent.com/block/buzz/f34e87a8c083d36abf20422ac9d8ca2c43a49af6/pr-6714--01-light-theme.png)

#### Dark theme
Persistent addressing remains clear and legible in the dark composer
treatment.

![Dark theme persistent agent
addressing](https://raw.githubusercontent.com/block/buzz/f34e87a8c083d36abf20422ac9d8ca2c43a49af6/pr-6714--02-dark-theme.png)

### Reproduction steps

1. Open a channel with an eligible agent and enable persistent agent
addressing.
2. Insert an agent mention and confirm it appears as a normal editable
chip while the compact avatar tray reflects the same persistent
recipient.
3. Send several messages and confirm the agent remains addressed, the
mention restores without an empty-composer flash, and sent messages do
not duplicate the addressed agent.
4. Remove the agent's final mention chip and confirm its persistent
address clears. Add unrelated draft text, explicitly unpin the agent,
and confirm that text remains intact.
5. Press `Cmd+Shift+M` on macOS or `Ctrl+Shift+M` on Windows/Linux.
Confirm it toggles the current addressed agent or, when none is
selected, chooses the most relevant eligible agent.
6. Verify recent explicit channel/thread mentions influence shortcut
selection, while DM participant fan-out does not.
7. Force a failed send and confirm the addressed avatars shake and the
full draft is restored; confirm successful sends use the subtler bounce
feedback.
8. Repeat in the thread composer, after switching channels, in dark
theme, with reduced motion enabled, and at a narrow composer width.

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

**desktop/src/features/channels/ui/ChannelPane.tsx**  
Feeds recent explicit agent mentions into the composer for contextual
shortcut selection.

**desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts**
and tests
Show tag-backed addressed agents only when they are not already
represented by inline mention text.

**desktop/src/features/messages/lib/mentionCandidates.ts**,
**mentionRanking.ts**, **mentionSuggestionMapping.ts**,
**recentMentionPubkeys.ts**, and tests
Model agent eligibility and activity, derive explicit mention recency,
and rank the best shortcut recipient.

**desktop/src/features/messages/lib/useActiveAgentPubkeys.ts**,
**useDefaultAgentSuggestion.ts**, and **useMentions.ts**
Expose active-agent and recent-recipient context to mention consumers.

**desktop/src/features/messages/ui/ComposerAddressControls.tsx** and
tests
Render the compact persistent-recipient tray with enter, exit, send,
failure, layout, and reduced-motion states.

**desktop/src/features/messages/ui/MessageComposer.tsx** and
**MessageComposer.types.ts**
Connect editable mention text, persistent addressing, shortcut
selection, draft restoration, and send feedback.

**desktop/src/features/messages/ui/MessageRow.tsx**  
Avoids duplicating addressed agents already rendered as inline mentions.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**  
Brings the same recency and persistent-addressing behavior to thread
composers.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**,
**useAutoPinMentionedAgents.ts**, **useAlwaysAddressShortcut.ts**,
**useMentionSendFlow.ts**, and tests
Synchronize mention chips with persistent audience state, promote
eligible mentions, handle shortcut toggling/default selection, and
preserve recipients through send success or failure.

**desktop/src/features/messages/ui/useComposerPasteHandler.ts** and
**useMentionSendFlow.types.ts**
Keep the composer implementation focused by extracting paste and
send-flow boundaries.

**desktop/src/shared/lib/keyboard-shortcuts.ts**  
Documents `Cmd+Shift+M` / `Ctrl+Shift+M` as the agent-address shortcut.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**  
Exercises persistence, selection, removal, sends, failures, restored
drafts, themes, reduced motion, and narrow layouts.

</details>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-25 18:44:25 +00:00
Kalvin C
a526dca9bc feat: navigate images across message threads (#6705)
## Summary
- navigate across image occurrences that are currently rendered inside
the nearest explicit open-thread DOM scope
- preserve rendered DOM order, duplicate occurrence identity,
hidden-spoiler exclusion, preview-image entry parity, and the exact
thumbnail return target
- keep adjacent timeline messages and unrelated surfaces as separate
galleries
- make Copy and Download follow the current gallery item, including when
a link-preview image opens the gallery first and navigation reaches
Markdown media

## Scope contract

This is intentionally a **rendered-media gallery only**. Gallery
membership comes from mounted image triggers under the nearest explicit
thread scope. Collapsed or unloaded descendants are excluded until the
thread UI renders them. There is no canonical thread fetch, descendant
projection, depth override, or synchronous message reparse in this
change.

`ChannelPane.tsx` remains unchanged from `main`;
`useSearchHighlightProps` and both timeline/thread prop spreads are
preserved.

## Verification
- rebased onto `origin/main` at
`113a33b7e49b7173ee1767c49ef2f49c63803034`
- `git diff --check`
- differential file-size check
- full Desktop check (Biome plus text/pubkey policy checks; only
pre-existing informational diagnostics)
- Desktop TypeScript typecheck
- Desktop production build and rebuilt E2E build
- full Desktop JS units: `5,476/5,476` passed
- full Desktop Tauri workspace tests passed (one documented native
performance test ignored)
- rebuilt image gallery Playwright spec: `14/14` passed
- search-highlight smoke cases: `5/5` passed
- reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0`

The four added E2E cases cover cross-message thread navigation and
return targeting, rendered-only exclusion of collapsed descendants,
preview-first current-item actions, and adjacent-message isolation.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-25 11:34:16 -07:00
Luke Tornquist
113a33b7e4 Add database pressure observability (#6700)
## Why

Database pressure currently collapses several distinct delays into one
symptom. This adds the evidence layer needed to distinguish pool
acquisition wait, logical database operation time, advisory-lock wait,
and selected transaction duration before changing timeout or retry
policy.

This is the phase 2 Lane A observability bundle for
[#26](https://github.com/TheSentinel454/buzz/issues/26),
[#28](https://github.com/TheSentinel454/buzz/issues/28), and
[#33](https://github.com/TheSentinel454/buzz/issues/33). It is stacked
on #6668.

## What

- Record explicit reader/writer checkout wait and acquisition outcomes
with `buzz_db_pool_acquire_wait_seconds` and
`buzz_db_pool_acquisitions_total`.
- Extend the compile-time `#[datastore_span(name = "...")]` seam with
`buzz_db_operation_duration_seconds`, so operation labels remain static
source literals instead of request data.
- Route correctness-critical replacement, membership, push-gate,
deletion, and migration/schema-safety advisory locks through one
observer without changing their SQL, order, scope, or blocking behavior.
- Measure six internally owned transaction lifetimes with
`buzz_db_transaction_duration_seconds`, starting after `BEGIN` succeeds
and ending after explicit commit/rollback or scope exit.
- Emit root slow-operation warnings at 500 ms, logging the first slow
completion and then 1/100 per call site with only `operation`,
`outcome`, and `elapsed_ms`.
- Document names, units, fixed label vocabularies, measurement
boundaries, and blind spots in this PR description.

Fixed labels are deliberately small:

- `pool_role`: `writer`, `reader`
- `lock_type`: `replacement`, `membership`, `push_gate`, `deletion`,
`migration_schema_safety`
- `outcome`: `success`, `error`, `timeout` where SQLx/PostgreSQL can
distinguish it accurately
- `operation`: compile-time datastore names plus the six closed
transaction operation names documented in the runbook

No metric or slow warning contains community IDs, event IDs, event
kinds, coordinates, d-tags, SQL/query text, query IDs, returned errors,
or event content.

## Coverage boundaries

- Operation duration is the complete annotated logical function body,
not pure SQL execution; it may include implicit checkout, lock wait,
nested operations, and application work. Cancelled futures do not reach
its completion hook.
- Pool timing covers explicit helper checkouts, including proved-reader
routing and selected writer-owned transactions. Implicit SQLx checkout
through `&PgPool` remains folded into operation duration.
- Lock timing covers application-side blocking locks in the five named
families. Trigger/stored-procedure locks, channel-TTL locking, the usage
try-lock, and the audit service session lock remain outside this slice.
- Transaction timing covers only the six wholly owned boundaries
documented in the runbook. It excludes pool wait, `BEGIN`, asynchronous
rollback cleanup after an early return, and caller-owned
`Db::begin_transaction` lifetime.

## Relationship to #6229

#6229 is the incident-driven timeout precursor. This PR does not add or
change `statement_timeout`, `lock_timeout`,
`idle_in_transaction_session_timeout`, retries, audit durability, or
client-visible conflicts. It provides the missing distributions needed
to evaluate those policies later and intentionally leaves #6229's open
audit retry/durability finding untouched.

The branches overlap in `crates/buzz-db/src/lib.rs` and
`crates/buzz-db/src/migration.rs`, so a later rebase may need textual
conflict resolution, but the behavior is complementary rather than
duplicated.

## Risk assessment

Moderate-low. The primary risk is instrumentation overhead and added
static series. Cardinality is source-bounded, slow logs are
sampled/redacted root events, and the lock/transaction changes wrap
existing awaits without changing policy or ordering.

## Verification

Author workstation: `buzz-tornquist-db-pressure-observability`
(`2010927`), exact head `d7cf833e26c528adfcde3917ded80daf6f4ddac9`,
parent `6f50e6b2b2a996349149af61d35bdd6a355f77fd`.

- `cargo fmt --all --check` — passed
- `cargo clippy -p buzz-datastore-tracing -p buzz-db -p buzz-audit -p
buzz-search -p buzz-relay --all-targets -- -D warnings` — passed
- `cargo test -p buzz-datastore-tracing --quiet` — 4 passed
- `cargo test -p buzz-db --quiet` — 109 passed, 200 ignored
- `cargo test -p buzz-audit -p buzz-search --quiet` — 16 passed, 25
ignored
- `cargo test -p buzz-relay --lib --quiet -- --test-threads=1` — 906
passed, 48 ignored
- Native PostgreSQL focused tests for pool success/timeout/error, lock
success/contention/timeout/error, replacement, membership serialization,
push ordering, deletion fencing, migration/schema exclusion, and reader
fallback — 8 passed

The default-parallel relay run passed once; subsequent runs exposed the
existing load-sensitive
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` 504 at
the end of the suite. That test passes in isolation and the full relay
suite passes serially.

Independent exact-head review workstation:
`buzz-tornquist-db-pressure-observability-review` (`2013067`).
Formatting, the same all-target clippy command, datastore
instrumentation tests, DB unit tests, source privacy guards, and
diff/non-goal audits passed; no review findings.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
2026-08-25 13:00:22 -04:00
tulsi
12f3fea26e revert fixed mention highlight (#6716)
**Category:** fix
**User Impact:** Mention chips now follow the active theme without a
fixed yellow fill and remain readable across every supported theme.

**Problem:** PR #6696 added an opaque yellow treatment to every human
and agent mention. Removing that fixed color exposed another issue: some
theme accent colors do not provide WCAG AA contrast when also used as
normal-size mention text.

**Solution:** Remove the fixed yellow treatment and keep the active
theme accent as a subtle translucent chip background. Mention labels use
a light/dark semantic foreground selected for readable contrast,
including hover states.

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

**desktop/src/shared/styles/globals/markdown.css**
Removes the fixed-yellow treatment, retains theme-derived accent
backgrounds, and applies an AA-safe semantic foreground to human and
agent mention labels in resting and hover states.

**desktop/src/shared/styles/globals/theme.css**
Removes the obsolete fixed-yellow tokens and defines light/dark mention
foreground values.

**desktop/tests/e2e/mentions.spec.ts**
Adds rendered contrast coverage for resting and hover states across
every supported syntax theme, compositing translucent chip and ancestor
surfaces before checking the 4.5:1 threshold.

</details>

## Reproduction steps

1. Open a message containing a human or agent mention.
2. Confirm its subtle background follows the active theme accent rather
than using yellow.
3. Hover the mention and confirm the accent background strengthens while
the label remains readable.
4. Switch between default, light, dark, and custom syntax themes and
confirm mention labels remain legible.

This is a focused visual rollback of the mention treatment introduced by
#6696. The channel notification badge changes from that PR remain
intact.

---------

Signed-off-by: tulsi <tulsi@block.xyz>
2026-08-25 09:49:43 -07:00
tulsi
29f2054c69 highlight search terms in results and messages (#6702)
**Category:** fix
**User Impact:** Search terms are now highlighted in yellow in Cmd+F
results and in the message opened from a result.

**Problem:** Search returned relevant messages, but users still had to
reread each preview and destination message to discover where the query
appeared. Search navigation could also lose or misapply highlighting
during rapid typing, repeated navigation, thread opening, and forum
navigation.

**Solution:** Render match-focused previews with a shared yellow
treatment, carry the result query through navigation, and apply it only
to the clicked destination. The implementation binds highlights to the
debounced result set, supports token and prefix matching, and handles
channels, threads, forums, diffs, and wave messages consistently.

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

**desktop/src/app/AppShell.tsx**
Carries the active result query into search-hit navigation so the
destination can preserve the user's context.

**desktop/src/app/navigation/searchHitEventCache.ts**
Adds a bounded, one-shot cache for result queries and navigation IDs
alongside cached search-hit events.

**desktop/src/app/navigation/searchHitNavigation.test.mjs**
Covers query retention, one-shot consumption, repeated same-route
activations, forum posts, forced routing, and cancellation.

**desktop/src/app/navigation/searchHitNavigation.ts**
Associates each search-result activation with a unique navigation ID and
forwards it to channel or forum destinations.

**desktop/src/app/navigation/useAppNavigation.ts**
Extends channel and forum navigation to carry search navigation state
without changing normal navigation behavior.

**desktop/src/app/routes/ChannelRouteScreen.tsx**
Consumes search highlight state after navigation and retains it for the
selected message while clearing it on community context changes.

**desktop/src/app/routes/channels.$channelId.posts.$postId.tsx**
Validates and forwards forum search-navigation IDs.

**desktop/src/app/routes/channels.$channelId.tsx**
Validates and forwards channel search-navigation IDs.

**desktop/src/features/channels/ui/ChannelPane.tsx**
Passes the selected result ID and query into the main timeline and open
thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Defines the destination-highlight contract for channel panes.

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Routes destination highlighting to either forum content or the channel
timeline.

**desktop/src/features/channels/ui/ChannelScreen.types.ts**
Defines route-level search-highlight inputs.

**desktop/src/features/channels/ui/ForumChannelContent.tsx**
Forwards search context into expanded forum threads.

**desktop/src/features/forum/ui/ForumThreadPanel.tsx**
Highlights the matching text in the selected forum post or reply.

**desktop/src/features/forum/ui/ForumView.tsx**
Carries selected-result context from forum routing into the thread
panel.

**desktop/src/features/messages/ui/DiffMessage.tsx**
Forwards the query into diff descriptions and rendered diff content.

**desktop/src/features/messages/ui/DiffViewer.tsx**
Provides a highlighted match excerpt for structured diffs and highlights
fallback raw diff text.

**desktop/src/features/messages/ui/MessageRow.tsx**
Passes destination queries into normal Markdown, diff, and wave message
renderers.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**
Applies highlighting to the selected thread head or reply without
tinting unrelated messages.

**desktop/src/features/messages/ui/WaveMessageAttachment.tsx**
Highlights matches in wave-message fallback text.

**desktop/src/features/search/lib/searchMatch.test.mjs**
Covers case-insensitive, literal, multi-term, prefix, one-character, and
late-preview matches.

**desktop/src/features/search/lib/searchMatch.ts**
Centralizes token extraction, case-insensitive match splitting, and
match-focused preview generation.

**desktop/src/features/search/ui/HighlightedSearchText.tsx**
Adds the reusable accessible mark renderer used by result and
specialized message surfaces.

**desktop/src/features/search/ui/TopbarSearch.tsx**
Highlights result previews, centers excerpts around matches, strips
search operators, and hides stale results during debounce transitions.

**desktop/src/features/sidebar/ui/AppSidebar.types.ts**
Updates the sidebar search callback contract to include the result
query.

**desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx**
Passes query-aware result selection through the pinned search entry
point.

**desktop/src/shared/lib/rehypeSearchHighlight.ts**
Uses the shared matcher to mark every matching Markdown text segment
while leaving code blocks untouched.

**desktop/src/shared/lib/searchHighlightStyle.ts**
Defines one yellow highlight treatment for light and dark themes.

**desktop/src/shared/ui/markdown/nodeCache.test.mjs**
Verifies all Markdown matches, one-character scoped searches, code
exclusion, and transient cache behavior.

**desktop/src/shared/ui/markdown/nodeCache.ts**
Enables transient highlighting for one-character scoped searches without
polluting the Markdown cache.

**desktop/tests/e2e/smoke.spec.ts**
Exercises result and destination highlighting, same-route forum
activation, and stale-query suppression end to end.

</details>

## Reproduction steps

1. Open a channel containing a longer message and press Cmd+F.
2. Search for a word that appears later in the message, such as
`mentions`.
3. Confirm each matching result shows the word in yellow and keeps the
match visible in its preview.
4. Open a result and confirm the same term is highlighted in yellow in
the destination message.
5. Repeat with a thread reply or forum post, and quickly change the
query to confirm stale results are not selectable.

## Demo

- **Before:** Search results returned the correct message but offered no
visual indication of where the query matched; opening the message also
showed no match treatment.
- **After:** Every matching term is marked in yellow in the result
preview and in the exact channel, thread, or forum message opened from
that result.

---------

Signed-off-by: tulsi <tulsi@block.xyz>
2026-08-25 16:22:12 +00:00
Kalvin C
9b6a637d01 fix(desktop): make lightbox zoom controls interactive (#6710)
## Summary

- Replace the decorative lightbox zoom icons with accessible Zoom out
and Zoom in buttons.
- Route button clicks through the existing 1x–3x, 5% stepped zoom state
without dismissing the viewer, including clicks on SVG icon descendants.
- Extract the zoom toolbar so `markdown.tsx` remains below the
repository file-size threshold.

### Testing

- `pnpm build:e2e`
- `pnpm exec playwright test image-attachment-gallery.spec.ts
--project=smoke` — 10 passed
- `pnpm typecheck`
- Full desktop unit suite — 5,397 passed
- Pre-push checks — file-size gate, desktop checks, typecheck, and
desktop tests passed
- `git diff --check`

### UI evidence

The rendered lightbox flow is covered by the gallery smoke test: Zoom
out is disabled at 100%, Zoom in changes the value to 105%, Zoom out
returns it to 100%, and the dialog remains open throughout. Existing
gallery, keyboard, spoiler, context-menu, and close-path assertions also
pass.

---------

Signed-off-by: sanic <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: sanic <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
2026-08-25 09:09:13 -07:00
Brad Seiler
d12dea4e67 Support community deletion in versioned media buckets (#6738) 2026-08-25 09:02:18 -07:00
klopez4212
bb5b9357a7 Fix TipTap editor mount race (#6779)
## Summary

- wait for TipTap's mount lifecycle before reading the selection
formatting tray's editor DOM
- detach and reattach DOM listeners across editor view unmounts and
remounts
- cover mounted and pre-mount editor view access

## Testing

- `just ci`
- `pnpm build:e2e`
- `pnpm exec playwright test
tests/e2e/composer-selection-formatting.spec.ts --project=smoke --grep
"right-clicking selected composer text"`
- Native Builderlab staging: reproduced the pre-mount crash on main,
relaunched with the fix, and verified the composer mounts without the
TipTap error

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-25 16:50:21 +01:00
Will Pfleger
a1219070fa feat(buzz-agent): gate LLM tool calls on session/request_permission (#5712)
## Summary

buzz-agent now authorizes every LLM-issued MCP tool call through
`session/request_permission` before it executes, instead of running
tools unconditionally. The agent **always asks**; the client applies
`BUZZ_ACP_PERMISSION_POLICY` and answers. buzz-agent never reads the
policy — this keeps the policy decision on the client side, matching the
layering of the other ACP harnesses, and avoids duplicating policy logic
that would drift.

### Broker

A crate-local `PermissionBroker`
(`crates/buzz-agent/src/permission.rs`), owned by `App` for the
connection lifetime, owns the full request-correlation lifecycle:

- **Process-wide admission.** A global `Semaphore`
(`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`, default 32, validated `>= 1`) is
acquired *before* any correlation entry is inserted. This is the
security bound: the per-turn tool semaphore is constructed fresh per
turn and `max_sessions` is unbounded by default, so neither bounds
simultaneously-outstanding asks process-wide.
- **Abort-safe cleanup.** A successful admission returns a
`PendingPermission` lease that owns the admission permit and the
correlation id. Its `Drop` synchronously removes the still-pending entry
and releases the slot, covering task abort/panic that bypasses the
normal `run_prompt` tail.
- **At-most-once resolution.** `deliver` claims (removes) the entry
*before* waking the waiter, so each id resolves once and a later lease
`Drop` is a no-op. Unknown/late ids are logged and dropped; only ids the
broker minted (`perm-<n>`) are recognized.
- **Single absolute deadline.** Admission, request enqueue, and response
wait all share one deadline (`BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`,
default 330s, validated `>= 1`), so a saturated call cannot outlive one
timeout window even when a stalled writer blocks the enqueue.
Cancellation races inside every wait — resolution never depends on the
outer abort drain. A writer that dies mid-connection (stdout closed, or
a blocking write that only surfaces its error at flush) is
connection-fatal: it cancels all sessions, which resolves any ask
waiting on a reply that can never be written.

### Wire

`request_permission_params` (`crates/buzz-agent/src/wire.rs`) is
version-aware, keyed on the protocol version negotiated at `initialize`
and stored on `App` for the connection lifetime (never derived from a
later mutable session field). v2 nests the tool call under `subject:
{type: "tool_call", toolCall}` with top-level `title`/`options`; v1 uses
the legacy top-level `toolCall`. No hybrid shape. Both offered options
(`allow_once`, `reject_once`) carry `optionId == kind`, so the client's
`kind`-based selection and this side's `optionId`-based predicate agree
without a lookup table.

### Gate

In each spawned tool task (`crates/buzz-agent/src/agent.rs`) the
sequence is: acquire per-turn permit → argument-shape validation →
broker admission + request + wait → cancellation recheck →
`emit_in_progress` → `mcp.call`. Argument-shape validation is hoisted
out of `mcp.rs::do_call` into `validate_arg_shape` so a malformed
non-object argument is rejected locally without prompting for a call
that could never execute.

Authorization is fail-closed, stated once in `evaluate`: execute IFF
`outcome.outcome == "selected"` **and** the selected `optionId` equals
the offered allow option. Every other shape (reject, cancelled, JSON-RPC
error, malformed, unknown outcome, wrong/unknown option, timeout,
wire-channel closure) denies with a synthetic tool error, and the turn
continues.

### Scope

Only LLM-issued MCP calls are gated. The built-in `load_skill` tool and
`call_hooks` lifecycle calls (`_Stop`, `_PostCompact`) are exempt — they
are not model-issued. `readOnlyHint` is never treated as a security
boundary.

First cut ships `allow_once`/`reject_once` only; session-scoped grants
are deliberately out of scope.

### Related issue

Part of #4938. This PR and
[#5106](https://github.com/block/buzz/pull/5106) jointly implement the
feature: #5106 is the client-side policy engine and permission cards;
this PR is buzz-agent's asking side (`session/request_permission`).
Neither closes #4938 alone.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-25 11:49:07 -04:00
Brad Seiler
931747c9c4 Add staging dev relay image workflow (#6709)
## Summary

Adds a manual, collaborator-triggered workflow for publishing pre-merge
Buzz relay runtime images for bb-block staging.

- Resolves a canonical `block/buzz` branch or tag to an immutable commit
SHA before checkout.
- Builds the relay runtime image for `linux/amd64` and `linux/arm64` and
publishes a single OCI index.
- Tags each publication uniquely as `dev-sha-<full
SHA>-run-<run_id>-<run_attempt>` so no tag is ever reused against the
immutable-tag utility ECR pull-through cache (rebuilds of the same SHA
aren't byte-identical, given mutable base tags and `apt-get update`).
- Uses the staging-only `ghcr.io/block/buzz-staging-dev` namespace,
which maps to a distinct utility ECR pull-through path.
- Restricts dispatch to `block/buzz` on `refs/heads/main`; job
permissions are narrowly scoped to `contents: read` plus `packages:
write`.
- Emits a deployment summary with the exact BPCI `repository` and unique
`tag` values, plus the merged manifest digest.
- Keeps the existing production/release image workflow unchanged.

The first real workflow run must confirm GHCR package creation/access
and utility ECR pull-through import for the new package.

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: tornquist <tornquist@squareup.com>
2026-08-25 11:23:17 -04:00
Luke Tornquist
9d1e4b2576 Extract community persistence (#6668)
## Why

Community persistence is the next incremental `buzz-db` store
extraction, keeping tenant lifecycle SQL, records, tests, and
instrumentation out of the database runtime module without changing
behavior.

## What

- Move community records and the existing `impl Db` operations into
`community.rs` while preserving crate-root re-exports.
- Move focused PostgreSQL tests with the implementation and enforce
single ownership for each method and datastore span.

## Risk Assessment

Low — this is a structural move of the existing records, SQL, method
bodies, and focused tests; database runtime concerns, schema, and
behavior remain unchanged.

## References

- Architecture guardrail:
https://github.com/TheSentinel454/buzz/issues/34
- Incremental tracker: https://github.com/TheSentinel454/buzz/issues/2
- Primary task: https://github.com/TheSentinel454/buzz/issues/5
- Stacked on: https://github.com/block/buzz/pull/6660

Generated with Codex

Signed-off-by: tornquist <tornquist@squareup.com>
mobile-v0.14.0-rc.5 mobile-v0.15.0-rc.1
2026-08-25 10:05:32 -04:00
klopez4212
8b812017a7 Fix mobile Huddle agent voice turn states (#6611)
## Summary

- ignore mobile room tone when deciding whether human speech should
interrupt agent audio
- show a subtle waiting animation while an agent prepares a response,
then restore its avatar when speech begins
- render agents from Huddle membership before their audio peer starts
transmitting

## Testing

- `just ci`
- signed Release build installed and launched on a physical iPhone
- live Huddle voice and agent-membership behavior verified on device

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-25 14:59:19 +01:00
klopez4212
9aa332af03 Add inline profile camera capture (#6680)
## Summary

- add inline profile photo capture with smooth avatar-to-viewfinder
transitions
- add close, flip, shutter, retry, and use-photo states with haptics and
front-camera mirroring
- polish iOS liquid-glass controls and avatar editor motion

### Related issue

Follow-up to #6583.

### Testing

- just mobile-check
- just mobile-test (1,743 tests)
- built, installed, and launched on Pixel 10 and iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 14:58:55 +01:00
klopez4212
822c5ab231 Hide Huddles in mobile agent DMs (#6676)
## Summary
- hide the mobile Huddle action in one-to-one agent DMs
- preserve Huddles for human DMs and group DMs

## Testing
- just mobile-check
- flutter test (1,662 tests)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-25 08:18:22 +01:00
ss-dev-02
91a5754ad5 fix(desktop): keep a reader's work block open when a later answer regroups it
A reader who opened a finished work block to read its steps was folded shut by
the agent posting another message — an event they did not cause and could not
predict.

Work blocks are derived, not stored. `groupConversationWorkBlocks` rebuilds them
every render and ids each one after its first step, and `findFinalAnswerId`
exempts only the LAST assistant message from the block. So a second assistant
message demotes the first: the earlier answer becomes work, and the runs on
either side of it merge.

    frame 2  work-block:th:1[th:1,tool:1]  msg:1  work-block:th:2[th:2,tool:2]
    frame 3  work-block:th:1[th:1,tool:1,msg:1,th:2,tool:2]  msg:2

`work-block:th:2` stops existing, React unmounts it, and the `useState` holding
the reader's expansion goes with it. The block they were reading is still on
screen — it is just inside a different block now — so this is not a case where
the intent has nowhere to live.

The choice therefore moves above the block, keyed by the STEP ids it was taken
on rather than by block id, since the steps are what the reader's intent was
actually about and they survive regrouping. Two rules follow from the merge:

- **An open choice wins over a folded one.** A merged block can carry both, and
  the two are not symmetric: showing steps a reader did not ask for costs them a
  scroll, hiding steps they did ask for loses what they were reading.
- **A choice is recorded against every step in the block, not just the first.**
  Recording only the first leaves the absorbed block's stale `open` entry behind,
  and since open wins the read, the merged block could never be folded again —
  the reader's click would do nothing. Caught by mutation, not by inspection; the
  first version of the test passed against it, so it now asserts the fold-back.

`useWorkBlockDisclosureStore` returns `null` rather than a no-op store when no
transcript provides one, and the block falls back to local state. A no-op default
would make a block mounted on its own silently swallow every click — how most of
this component's tests mount it — and local state is the correct behaviour in
isolation, where nothing is regrouping anything. A provider emits no DOM, so
`default`/`compactPreview` markup stays byte-identical (the byte-for-byte
baseline test covers this).

Verified by mutation, each applied and reverted in isolation:

- bypass the shared store (i.e. the pre-fix behaviour) — the new test fails on
  the merge frame, `0 !== 1` open blocks, which is exactly the reader-visible
  symptom.
- record the choice against only the first step — fails the fold-back
  assertion, `1 !== 0`.

The test drives the real `AgentSessionTranscriptList` across the three frames so
the actual regrouping runs, and asserts the steps are on screen rather than
trusting a block that reports itself open.

Full desktop suite 5492 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 22:36:47 -07:00
ss-dev-02
d02437981a test(desktop): pin the plain next-turn gap and stop the 5-minute suite hang
Two review corrections to the previous commit, neither changing production
behaviour.

**The gap test was pinning the wrong sequence.** Its middle frames put a
`session/new` card between `turn_started` and `session_resolved`. That card
renders as a trailing `single` block, which moves `streamingItemId` off the
previous turn's work block on its own — so those frames pass against the old
block-walking code and prove nothing. Measured over the revert mutation:

    SEQ A f1  turn_started only                  liveTurnId=turn-1 tail=tool:2  BUG
    SEQ A f2  +session_resolved, no session/new  liveTurnId=turn-1 tail=tool:2  BUG
    SEQ B f1  +session/new                       liveTurnId=turn-1 tail=null    masked
    SEQ B f2  +session/new +session_resolved     liveTurnId=turn-1 tail=null    masked

The plain sequence — no session restart, which is the overwhelmingly common
path — now leads the frame list, and the restart sequence is kept as a
separately labelled path that also has to settle. Reverting either half of the
fix now fails on frame 1 of the plain sequence rather than on a frame that a
restart card was carrying.

**A five-minute gc timer was being waited out on every run.** The orphaned
suite's own `QueryClient` took React Query's default 300000ms `gcTime`. The one
test that reaches the message-bubble presenter's `useQuery` leaves a query
uncollected, so a 300s timer is armed at teardown and node:test waits it out
before exiting: the file's tests sum to ~2s, the wall was ~303s, and every test
passed, so nothing pointed at the cause. `gcTime: 0` takes the standalone file
from 303s to 7s, and the full desktop suite from 307s to 102s.

The shared rig (`AgentSessionWorkBlockTestRig`) creates a byte-identical client
and gets the same line. It is dormant today — only the relay-bubble test drives
a `useQuery`, and that test builds its own client — but the failure mode if it
ever arms is a silent five-minute hang with no failing assertion, which is
expensive to diagnose and free to prevent.

Verified with a negative control rather than on the passing tree alone: with the
orphaned file restored to its previous blob (17ce5d633) the same command in the
same shell takes 303s, so the number is attributable to the one line.

Contrary to the review note that prompted this, the
'Promise resolution is still pending' marker is NOT present on the previous
commit's tree — grep counts 0 both with and without the fix — so it is not
claimed as fixed here.

Full desktop suite 5491 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 22:19:24 -07:00
ss-dev-02
eeb2f702ac fix(desktop): guard the work-block pulse and stop a finished block going live
Two reader-visible bugs in the focus-mode work block, both found reviewing
this PR rather than by a test.

**The running step's pulse ignored reduced motion.** `WorkBlockRailGlyph`
applied bare `animate-pulse`. `useWorkBlockMotionEnabled` skips the fold's
height animation, but it cannot reach a keyframe animation applied by a
utility class, and none of the app's 20 `prefers-reduced-motion: reduce`
blocks matches `.animate-pulse` — every one is scoped to a
`buzz-*`/`motion-*`/`t-skel-*` class. So a reader who asked for no motion got
an indefinite pulse. Now `motion-safe:animate-pulse`, which this repo's
Tailwind (4.3.0) compiles to the same declaration wrapped in
`@media (prefers-reduced-motion: no-preference)` — and which matches every
other pulse in this feature (`AgentStatusBadge`, `ManagedAgentRow`).

That swap also repaired three assertions it would otherwise have disarmed.
`motion-safe:animate-pulse` is a different class TOKEN, not `animate-pulse`
plus a modifier, so the suite's `.animate-pulse` selectors would have stopped
matching anything and passed unconditionally. Worse, they were already
vacuous: all three ran after `settleToStepCount(0)`, i.e. on a folded rail
with no rows, so "nothing pulses" held against a build where abandoned steps
pulse forever — the exact Codex finding this PR answers. They now assert on
rows that exist (expanded, or mid-settle) and match the exact class token, so
neither a substring nor a missing element can fake a pass.

**A finished block went live again between turns.** `lastTurnId` walked the
display blocks for the newest `turn` block, but a turn that has only emitted
setup lifecycle rows (`turn_started`, `session_resolved`) classifies to zero
segments and so produces no block at all. For the whole gap between
`turn_started` and the next turn's first renderable item, "newest turn with a
block" was therefore the turn that had already ENDED, and its own trailing
item became the streaming item: a settled 6-step block re-opened, dropped to
its last three steps behind a previous-steps disclosure, then folded back.
`turn_started` fires on every turn and the gap is real observer-stream
latency, so this was every turn, not an edge case.

Liveness now comes from the newest turn id in the item stream, which is why
`buildConversationTurnMeta` takes `items`. A trailing item is also only
reported as streaming when the live turn owns it — the same ownership rule
`toolEntryState` already applies to an `executing` status, for the same
reason: without it the newest block's tail holds a finished turn's block open
regardless of whose turn it is.

Tests, each mutation-checked in isolation so none of them is vacuous:

- pulse guarded, asserted under BOTH preference values (the class must not
  depend on it) — reverting to the bare class fails exactly that test;
  dropping the `running` guard fails the three retargeted negatives.
- the meta gap contract over the real 4-frame sequence built from a raw item
  stream through the real grouping, since the bug is precisely which turns do
  and do not produce a block — reverting either half of the fix fails it.
- the rendered consequence with a 6-step block (above the live window, where
  the symptom is loudest): folded summary and no rail across the gap.

Also fixed a fixture that named the wrong thing: "no turn at all" carried the
shared helper's default `turnId: "turn-1"`, so it was asserting that a turn
with no block reports no live turn — the opposite of the rule.

Full desktop suite 5491 passing / 0 failing, 81 suites; `tsc --noEmit` clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 21:50:44 -07:00
Taylor Ho
a8e1c66c4a fix(desktop): polish inline chip states (#6718)
**Category:** fix
**User Impact:** Inline chips now read more consistently, fit cleanly in
the composer, and show deleted message links with the same calm muted
treatment as unresolved links.

**Problem:** Deleted message links looked like destructive actions even
though they are informational, while mention chips had slightly uneven
vertical spacing, a high-set human icon, and could clip inside the
composer. **Solution:** Unify unavailable-state styling, tighten chip
spacing, optically align the human icon, and give composer chips enough
line height to paint without changing caret behavior.

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

**desktop/src/shared/styles/globals/markdown.css**
Makes chip padding vertically symmetric, moves only the human `@` icon
down by 1px, and shares muted colors between deleted and unresolved
message links while preserving their separate semantic classes.

**desktop/src/shared/styles/globals/composer.css**
Adds a composer-only line height derived from the text size and chip
padding so inline chips no longer clip while retaining inline caret
behavior.

**desktop/tests/e2e/entity-link-recipient-cards.spec.ts**
Verifies deleted and unresolved chips have matching computed colors
while deleted links retain their tooltip, semantics, and navigation
behavior.

**desktop/tests/e2e/mentions.spec.ts**
Covers composer chip height, clipping, the human icon's 1px optical
offset, and visual capture.

</details>

## Reproduction steps

1. Open a channel containing a link to a definitively deleted message
and compare it with a transient or unresolved message link; both should
use the muted unavailable treatment, while the deleted link still says
“Message deleted” and navigates to its fallback destination.
2. Insert a person mention in the composer; the chip should have
balanced vertical spacing and paint fully without clipping.
3. Compare a person mention with a channel chip; only the human `@` icon
should sit 1px lower.

## Screenshots

### Deleted message link

| Before | After |
| --- | --- |
| ![Deleted message link
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/deleted-chip-before.png)
| ![Deleted message link
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/deleted-chip-after.png)
|

### Composer chip polish

| Before | After |
| --- | --- |
| ![Composer mention chip
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/composer-chip-before.png)
| ![Composer mention chip
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6718/composer-chip-after-07411aba3.png)
|

## Validation

- `desktop/tests/e2e/entity-link-recipient-cards.spec.ts` +
`desktop/tests/e2e/mentions.spec.ts`: 82/82 passed
- Desktop unit suite: 5,397 passed
- Formatting and lint checks passed (existing informational warnings
only)
- Pre-push desktop check, typecheck, and unit tests passed at
`0255c3fd49f9244cd727d0dd20c514b3a1812152`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-24 20:45:42 -07:00
ss-dev-02
036bd38c3b test(desktop): split the work-block suite and prune the conversation harness
`AgentSessionWorkBlock.test.mjs` landed at 1,146 lines. Under today's rule
tables that passes, but only because the desktop ratchet's script roots
allowlist `.ts`/`.tsx` and silently skip `.mjs` — the gap #6736
(`ce1f1d427`) closes. Measured rather than assumed: with that rule table
cherry-picked, this file is the one remaining violation on C, and because
`allowedLineCount` grandfathers a base that already exceeds the max,
whatever count C lands with becomes the file's permanent ceiling on main.
1,146 is not a ceiling worth inheriting for a file that exists to cover a
single component.

Split on the seam the file already had, at `// -- Orphaned work --`, where
the subject changes from live-vs-finished *policy* to what an individual
*row* is:

- `AgentSessionWorkBlockTestRig.mjs` (311) — jsdom lifecycle, the item
  fixtures, `settle()`, `renderBlock()`.
- `AgentSessionWorkBlock.test.mjs` (352) — Live, Finished, fold animation,
  reader choice, rail glyph states.
- `AgentSessionWorkBlock.orphaned.test.mjs` (528) — orphaned work, the
  per-kind rail presentation, streaming re-render cost.

One rig, not a copy per file. The two suites run in separate processes
(node's runner is one process per file), so a second copy of the jsdom and
`matchMedia` setup could not *collide* — it would drift, and a drifted
ambient pin fails a fixture for a reason that has nothing to do with the
markup under test. That trap already cost this suite family two commits
(`2b7b0baf6`, `c91819706`).

The split forces one real behavioural change. `prefersReducedMotion` was
a module-level `let` the reduced-motion test assigned directly; ESM
bindings are read-only in importers, so once it lives in the rig that
assignment cannot work. It goes through `setPrefersReducedMotion(value)`,
with the rig's `afterEach` still resetting it. Proved non-vacuous by
stubbing the setter to a no-op: that fails exactly one test — the
reduced-motion one — and restoring it passes. Without that check the test
would have been asserting against a flag nothing sets, which is precisely
the way a split can silently disarm a test.

Behaviour is otherwise preserved, checked rather than assumed: the 30 test
titles across the two files are an exact set match with the 30 in the
single file, and every body is byte-identical apart from the setter call.

Harness prune, in the same revision because it is the same debt: with the
four `<details>` disclosure tests gone, `export const domWindow =
dom.window` had no consumer anywhere in `desktop/src` — it existed only
for `new domWindow.Event("toggle")`. Dropping just the `export` leaves an
unused local that Biome flags (`lint/correctness/noUnusedVariables`), so
the declaration and its now-false doc comment both go (576 -> 571).
`TRIGGER_TITLE` only loses its `export`; it is still used inside the
harness.

Ratchet with #6736's rules cherry-picked, run in the same shell as
`git rev-parse HEAD`:

- base `fd2e01799` (the current PR base, which is what CI resolves —
  `resolveBaseRef` returns `HEAD^1` under `GITHUB_ACTIONS`): exit 0.
- base `merge-base(origin/main)` = `db5617dd1`, which is what it resolves
  to locally and would resolve to if C retargets: exit 0.
- negative control at the pre-split tree, same rules, same base: exit 1,
  `AgentSessionWorkBlock.test.mjs: new -> 1146 lines (allowed 1000)`. The
  gate is measuring the thing this commit fixes.

Both suites green together, 30/30. `conversation` + `conversationChrome`
still green. `tsc --noEmit` clean, `pnpm check` findings identical to
main's baseline.

The split shape, the seam and the reduced-motion proof are dev-01's, handed
over as a verified patch (`OUTBOX/C_WORKBLOCK_SPLIT_HARNESS_PRUNE.patch`,
sha256 6cfbccc9…) rather than as a commit in this checkout. I read the
seam, reconstructed the tree from `fe57b7ac7` + that patch to confirm it
is exactly what was proposed, and re-ran the title/body comparison, the
setter mutant, both ratchet bases and the negative control here.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 19:26:20 -07:00
ss-dev-02
fe57b7ac7b fix(desktop): settle a work block whose running step has no live session
A tool item's `executing`/`pending` status is written when the step starts
and never revised if the agent dies first, so an abandoned step keeps it
forever. On the activity feed that was a stale row label. In the work
block this commit introduces it is a MODE: one `running` entry makes
`summarizeWorkBlock` report `isActive`, which suppresses the folded
summary line, holds the rail open and pulses a bullet. Scrolling back to
a crashed turn therefore showed the reader live work indefinitely
(Codex on #6536, discussion_r3848214880 — not stale, and worse here than
on the card it was filed against).

Status alone cannot answer "is this step happening?", so the block is
given the missing half: `AgentSessionTranscriptTurnMeta.liveTurnId`,
published by the list from the same display blocks it already reads for
`streamingItemId`. `toolEntryState` treats `executing`/`pending` as
`running` only when a session owns that step's turn.

A turn id, not a boolean. "Some turn is live" is not the question: an
agent that crashed during turn 1 and is now working on turn 2 IS live,
yet turn 1's abandoned step is no more running than before — a global
flag would keep it spinning in exactly the case a restarted agent makes
common. The comparison is explicit (`liveTurnId !== null && ...`) so an
item with no turn id cannot match a null live turn by `null === null`.

An abandoned step is reported as `settled`, not as a new state and not as
`failed`, per core-02:

- We do not know it failed — only that nobody finished it — so it must
  not count toward the folded line's `N failed`. It folds to a neutral
  `N steps`.
- No third entry state and no new glyph. It renders as the neutral step
  it is, with the same muted detail the list already shows for it outside
  a block. A visible "interrupted" marker would be a design addition,
  not a bug fix.

`liveTurnId` is read from the display blocks rather than from the
active-turn store, because the store's turn ids and the transcript's are
populated by different paths and a mismatch would silently gate every
step off — the mirror image of this bug. It is the last *turn* block, not
the last block: a compaction notice arrives as a `single` block after the
turn it belongs to, so reading the final block's kind would report no
live turn at all.

`projectWorkBlockEntries` takes the option as a required argument rather
than defaulting it, so a future caller that has not thought about
liveness cannot silently get the spins-forever behaviour back.

Non-vacuous by mutation, each mutant run in isolation against the unit
suite:

- gate removed (always `running`): 4 failures.
- gate inverted (`executing` never `running`): 5 — this is the fix that
  would have "passed" the bug report while breaking live work.
- truthiness instead of turn ownership (any live turn resurrects an old
  abandoned step): 1.
- `item.turnId === liveTurnId` without the null guard: 1.
- abandoned step reported `failed` instead of `settled`: 4.
- `liveTurnId` forced null in the meta builder: 2.
- `lastTurnId` reading only the final block: 1.

Measured in a real browser as well, not only JSDOM, because the failure
mode is presentational and animation-dependent — a unit test can assert
class names while the rendered rail is still wrong (the interim-note and
relay-send bugs on this branch were both invisible to the unit suite).
Two seeded scenarios in the preview harness, driving the real component
through the cover drawer under Buzz Dark:

- agent panic mid-step (the terminal `crates/buzz-acp` actually emits):
  folded label `3 steps`, rail collapses to 0 rows, `getAnimations`
  reports 0 infinite animations in the block, and opened the rail reads
  `["settled","settled","settled"]`.
- the same events with no panic: no folded line, rail open, states
  `["settled","settled","running"]`, and exactly 1 infinite animation —
  the running bullet really does pulse.

Both browser scenarios were mutation-checked too: removing the gate fails
the first and passes the second, inverting it fails the second and passes
the first, so neither can be satisfied by a one-sided fix. (Those two
specs are not in this commit — they need Slice A's `conversation` variant
pin to be reachable, which is not on this branch.)

The row outside a block is untouched: `buildCompactToolSummary` still
derives its own `running` from status, and no file under
`activityRenderClasses/` or `agentSessionToolSummary.ts` is modified here.

Verified at this tree: full desktop suite 5486 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing).

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 18:12:37 -07:00
ss-dev-02
ac794843a6 feat(desktop): fold focus-mode agent work into one transcript block
Focus mode gave every thought and tool step its own row and its own
disclosure, so a turn that did real work scrolled its answer off screen
behind a stack of chrome. This adopts berd's shipping transcript model:
within a turn, everything between the prompt and the answer that is
thinking, a tool step, or an interim agent note becomes one "work block"
— a thin rail of small glyph bullets that folds to a single "N steps"
line when the work is done.

Grouping is variant-aware AT THE LIST BOUNDARY rather than inside the
grouping module. `TranscriptDisplayBlockView` runs the additive
work-block transform only for `conversation`, so `default` and
`compactPreview` walk main's segments on the identical code path they
always did. That makes Slice B's byte-for-byte baseline fixture hold BY
CONSTRUCTION rather than by assertion, which is the property worth having
here: no future edit to the block can regress the other two variants
without first moving this branch.

Behaviour, mirroring berd:

- Live: the block is open and the rail IS the status, so there is no
  header line to restate it. The last three steps show in true arrival
  order; older ones go behind an "N previous steps" disclosure at the top
  of the rail, so a long run cannot push the answer out of view.
- Finished: folds to "N steps" with a chevron that rotates on open.
- The fold ANIMATES. `<details>` cannot do this — its content is either
  laid out or not, with no intermediate height — so the collapse would
  snap. A block that was live when it mounted renders open for a paint
  and then settles closed, giving the height tween a start state that was
  actually painted; a block already finished on mount (scrollback) never
  had a rail on screen and closes immediately, so the animation stays
  meaningful instead of firing on every mount.
- Reader choice wins: once the reader toggles a block, policy stops
  opening and closing it.

One deliberate departure from berd, per core-02: a finished block holding
a failure folds to "N steps · 1 failed". A bare count is the one thing a
reader cannot distinguish a clean run from a broken one by, and a fold
that hides a failure behind a neutral number invites them not to open it.
The rail bullet itself stays muted, per berd — failure is a glyph shape,
not a colour, so one bad step does not read as an alarm across the run.

Items are projected ONCE into rail entries, and the glyph, the body and
the folded line's counts all read that projection. Two independent
classifications of the same item is precisely how the headline and the
chain eligibility drifted apart on the abandoned tool-chain card, so both
render sites are exhaustive switches over the entry and neither asks
`item.type` again.

The projection is closed in the TYPE SYSTEM, not only by convention. An
earlier revision made the entry a product of independent fields
(`{ item, kind, state }`) with a catch-all `return "tool"`, which left two
things wrong that no test could see: a future `TranscriptItem` variant
would silently wear a wrench, and impossible pairs like
`{ kind: "note", item: <thought> }` stayed representable — so the body
switch still had to re-check `item.type` and render `""` on a mismatch it
could not otherwise handle. Meaning was therefore still derived in two
places.

Now:

- `WorkBlockItem` is the closed union of items a block admits, and
  `isWorkItem` is a type guard, so the membership decision is made once
  and every later stage receives the narrowed type. `admittedWorkItems`
  returns the narrowed array rather than a boolean because an `.every()`
  guard cannot narrow the array it tested.
- `WorkBlockEntry` is a discriminated union pairing each kind with its own
  item type, and fixing `state: "settled"` on the prose kinds. Both classes
  of impossible entry are now unrepresentable rather than defended against.
- `projectWorkBlockEntry` switches exhaustively over `WorkBlockItem` with
  no default, so admitting a new item type without deciding how it renders
  is a compile error (`TS2366: Function lacks ending return statement`),
  not a wrench.
- The body switch takes the whole entry, so narrowing on `kind` narrows
  `item` too. The `item.type === "thought" ? item.text : ""` fallbacks are
  gone because there is no longer a mismatch to fall back from.

Verified by compiling three mutants, each of which now fails `tsc` where
before it type-checked: admitting `plan` to `WorkBlockItem` without a
projection case (TS2366), projecting a thought as a note (TS2322 on
`item`), and giving a thought `state: "failed"` (TS2322 on `state`). The
runtime kind/item pairing is also asserted in
`agentSessionWorkBlockGrouping.test.mjs`, because types are stripped at
runtime and swapping the two prose branches by hand is the easy mistake —
that mutant fails the test.

The projection also fixes an ordering bug the old split invited: a tool
carrying a stale `isError` from a retry while the new attempt executes
reads as `running`, not `failed`, so a live block cannot fold its own
count to "N steps · 1 failed" while the work is still in flight.

**Interim notes suppress the identity row.** #6720 gives every
conversation-variant assistant message a 20px avatar + name row, which is
right for the turn's answer. A rail note is the same item type, so
routing it through that presenter would render a fully attributed agent
turn nested inside a muted step row — the agent apparently replying twice,
once inside the work it was doing. Notes render through a dedicated rail
prose body instead, keeping markdown and the focus code-block recipe by
providing the same `CodeBlockVariantContext` value the presenter would.
Done on this side rather than by reaching into #6720, so that PR keeps one
rule for what a message looks like. Notes share the thought's speech
bubble, matching berd's `progress` entry: both are the agent talking.

- `useControlledDisclosure` is deleted rather than reused. Its entire
  reason to exist was the `<details>` echo trap — `<details>` fires
  `toggle` for programmatic `open` changes indistinguishably from clicks,
  so a policy-driven open echoes back looking like reader intent. This
  block's trigger is a `<button>`, where the only thing that can call the
  handler is a real click. Keeping the guard would have been dead code
  masquerading as load-bearing. Nothing else in the tree imported it.
- berd brightens rail prose with `usePrimaryText={open}`; here the
  brightening is unconditional. A closed block unmounts its rows rather
  than dimming them, so there is no state in which rail prose is on
  screen and not in an open block — the flag's false branch would be
  unreachable. A test records that reasoning so the divergence is not
  mistaken for an oversight.

dev-01 flagged that `turnSegmentItems` does not know `work-block`, so a
thought followed only by block content would never settle its duration.
Tracing it: the meta is built from pre-transform display blocks, so no
`work-block` segment can reach that function — the reported bug cannot
fire. But the trace turned up something worse. `ConversationThought` was
the only reader of `thoughtDurationSecondsById` and
`formatThoughtDisclosureLabel`, and this commit deletes it, leaving ~100
lines of production code and ~200 lines of tests that only tested each
other. Fixing a bug in code with no reader would have preserved the
illusion that focus mode still shows "Thought for Ns" somewhere.

So the duration map, its label formatter and `elapsedSeconds` are gone.
`AgentSessionTranscriptTurnMeta` narrows to the one field that still has
a consumer: `streamingItemId`, which the work block needs because a
thought or note streaming in carries no status of its own. Its tests are
rewritten around what that hint must actually get right — the tail of a
live turn, skipping setup, expanding a summary segment to its last leaf,
and reporting nothing at all when the turn is idle.

Three further notes on translation rather than transcription:

- berd's bullet masks the spine with `bg-card` and its BOT-1599 note
  warns off `bg-background`. The rule is "mask with the surface the
  transcript is drawn on"; in Buzz that surface is the cover drawer,
  which is literally `bg-background`. The two tokens are NOT
  interchangeable here: `[data-buzz-content-surface]` locally overrides
  `--background` to `--buzz-content-dark` while `--card` keeps the theme
  value, so in Buzz Dark `bg-card` paints the bullet rgb(36,41,46) over an
  rgb(26,26,26) drawer — the exact BOT-1599 failure. Light mode matches
  under either class, so light-mode evidence alone would not have caught
  it. Copying the class would have followed the letter of berd's note
  against its point.
- Reduced motion is read via `matchMedia` — the way `TerminalSubstrate`
  reads it — not motion's `useReducedMotion`, which resolves the query
  once per process and caches it. That cache made the preference
  untestable (the assertion turned on module load order, not on the
  setting) and ignored a mid-session change.
- The memo takes the entry SPREAD into props, not the entry object, and
  its boundary is the step BODY rather than the whole row. The projection
  is rebuilt whenever the item array changes, so entry objects are fresh
  on every append and a memo keyed on one would never hit; spread, the
  compared props are `item` (reference-stable) plus two strings. Spreading
  also keeps the union intact, so the body switch still narrows `item`
  from `kind`. The row stays outside because the glyph depends on
  `isLast`, which changes for the previous last row on every append.

`ConversationThought` is deleted here rather than in dev-01's restyle,
per core-02's sequencing ruling: this is the commit that replaces it, so
reasoning stays visible in focus mode at every commit. Its four
disclosure-keyed tests are deleted rather than adapted — they assert a
`<details>` that no longer exists on that path. `default`/
`compactPreview` thought rendering is untouched.

Non-vacuous by mutation testing, each mutant run in isolation:

- note falls through to the tool kind: 4 failures.
- note routed through the message presenter: 2. Note given the wrench: 1.
- `entryState` checking failure before running: 1.
- rail bullet tinted on failure: 1. Prose muted instead of primary: 1.
- memo keyed on the projected entry object: 2 — this is the bug the
  projection actually introduced, caught by the pre-existing streaming
  cost tests before it shipped.
- streaming hint forced to null: 4. Summary segments not expanded when
  finding the tail: 1.
- windowing keyed off `open` instead of the reader's choice: 3. Policy
  already holds live blocks open, so this switches windowing off in
  exactly the case it exists for.
- reduced-motion preference forced false: 1.
- rail bubble suppression removed (the bug above): the relay-step test
  stops passing.

An earlier mutant also caught a test lying: the block-level "echo" test
passed with the guard removed, because the trigger is a `<button>` and
nothing was listening for `toggle` at all. It now asserts the structural
reason the trap cannot apply plus a repeated fold→reopen→fold cycle,
which is what a recorded echo would actually have disabled.

Relay posts are plain rail steps. A `buzz messages send` step classifies
as `renderClass: "message"`, so it renders through `CompactMessageSummary`
— 28px avatar, bordered speech bubble, timestamp, delivery-receipt
button. Correct in the activity feed, where a posted message is a
destination to open; on a muted rail it makes the agent appear to reply
in the middle of its own work. This is the same failure the interim-note
case avoids, reached by a different route: there the item IS an assistant
message, here it is a tool call that merely classifies as one. Caught by
looking at the seeded browser preview, not by a unit test — the JSDOM
suite was green while the rendered rail was wrong.

Suppressed with an explicit presentation signal
(`useIsInsideWorkBlockRail`, default false) rather than by reading the
transcript variant, because `conversation` alone is not the condition:
the same relay step rendered OUTSIDE a block in that variant should keep
its bubble. Both halves of that branch are now pinned by tests, so
suppressing the bubble everywhere cannot pass. Defaulting to false keeps
`default`/`compactPreview` markup byte-identical.

Rebased onto #6720 at `e8709554a` per core-02's B → #6720 → C ordering.

Verified at this tree: full desktop suite 5472 passing / 0 failing,
`tsc --noEmit` clean, `pnpm check` findings identical to main's baseline
(2 warnings + 2 infos, all pre-existing), px-text/pubkey-truncation/
file-size gates clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 18:12:37 -07:00
Luke Tornquist
8d2d0ff5ad Centralize replaceable event persistence (#6660)
## Why
Command persistence duplicated NIP-33 replacement SQL in the relay and
obscured the boundary between database runtime concerns and domain-store
behavior. This proving slice establishes that boundary inside the
existing `buzz-db` crate.

## What
- Centralize parameterized-replaceable coordinate locking, ordering,
replacement, watermark, and mention-indexing behavior in `buzz-db`
- Expose transaction-required replacement and ordinary-insertion seams
while keeping transaction ownership and workflow conflict messages in
the relay
- Cover concurrency, same-second ties, stale writes, replay, caller
rollback, nested rollback recovery, and mention-index atomicity with
focused PostgreSQL tests

## Risk Assessment
Medium — this changes core event persistence and command idempotency
paths, while intentionally preserving NIP-33, NIP-RS, mesh, and workflow
conflict semantics.

## References
- Architecture guardrail:
https://github.com/TheSentinel454/buzz/issues/34
- Proving slice: https://github.com/TheSentinel454/buzz/issues/3,
https://github.com/TheSentinel454/buzz/issues/4,
https://github.com/TheSentinel454/buzz/issues/6

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
2026-08-24 20:42:15 -04:00
Taylor Ho
e760c51820 feat(workflows): discover trigger filter values (#6712)
**Category:** improvement
**User Impact:** Workflow authors can discover people and messages while
configuring trigger filters, then see readable enriched labels instead
of raw identifiers.

**Problem:** Author and message filters required users to know and paste
raw public keys or event IDs, and configured workflows surfaced those
opaque values afterward. **Solution:** Add network-backed pickers and
presentation enrichment while keeping deterministic local public-key and
event-ID fallbacks authoritative whenever discovery is unavailable or
untrusted.

Related issue: none found.

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

**desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx**
Adds channel-aware author discovery, profile search, keyboard
navigation, loading states, and deterministic public-key fallback
selection.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**
Uses enriched trigger presentation when building the workflow card’s
readable summary.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**
Keeps Escape scoped to an active filter picker before allowing the
inspector or dialog to close.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**
Threads channel context into trigger filters and renders enriched
author/message summaries in the workflow sequence.

**desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx**
Adds paged channel-history discovery, message search, exact event
lookup, profile labels, keyboard navigation, and bounded results.

**desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx**
Renders compact author identity details and loading presentation inside
trigger summaries.

**desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx**
Connects author and message filter accordions to their pickers while
preserving selected and excluded condition semantics.

**desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts**
Resolves configured author keys to trusted display labels with
deterministic fallbacks.

**desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts**
Enriches configured message IDs only after validating the fetched event
and channel.

**desktop/src/features/workflows/ui/workflowAuthorCandidates.test.mjs**
Covers author candidate normalization, ordering, deduplication, and
fallback behavior.

**desktop/src/features/workflows/ui/workflowAuthorCandidates.ts**
Builds stable author candidates from channel members, profiles, and raw
public keys.

**desktop/src/features/workflows/ui/workflowConditionExpression.ts**
Allows message IDs to participate in basic trigger-filter parsing.

**desktop/src/features/workflows/ui/workflowDefinition.ts**
Accepts enriched trigger text when generating workflow card labels.

**desktop/src/features/workflows/ui/workflowMessageCandidates.test.mjs**
Covers event validation, source merging, deterministic ordering, and
exact-lookup enrichment boundaries.

**desktop/src/features/workflows/ui/workflowMessageCandidates.ts**
Validates message candidates by event kind, channel, and exact event ID
before permitting enrichment.


**desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs**
Covers readable selected/excluded author and message descriptions plus
loading fallbacks.

**desktop/src/features/workflows/ui/workflowTriggerDescription.ts**
Builds concise enriched trigger descriptions while retaining stable
raw-ID fallbacks.

**desktop/tests/e2e/workflow-local-controls.spec.ts**
Exercises picker discovery, selection toggles, Escape ownership, bounded
scrolling, and enriched workflow summaries.

</details>

## Reproduction steps

1. Open Workflows and create a workflow for a channel with members and
message history.
2. Choose **Reaction Added** as the trigger and expand **Author**.
3. Confirm channel members and fetched profile results are discoverable,
searchable, and keyboard accessible; choose one.
4. Expand **Message**, confirm recent channel messages appear in a
bounded list, and choose one.
5. Toggle either selected filter between **is** and **is not**, then
collapse the inspector and confirm the sequence summary stays readable.
6. Add a send-message step and create the workflow; confirm its card
uses the resolved author and message labels.
7. Repeat while discovery is unavailable and confirm raw public
keys/event IDs remain selectable and authoritative.

## Screenshots

### Author discovery

![Author picker showing discoverable channel members and profile
labels](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--01-author-discovery.png)

### Message discovery

![Message picker showing bounded channel history
discovery](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--02-message-discovery.png)

### Selected filter summaries

![Workflow builder showing readable selected author and message
filters](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--03-selected-filter-summaries.png)

### Enriched workflow card

![Workflow card showing enriched author and message
labels](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--04-enriched-workflow-card.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <0366ccd5ee09c2779a9d6bd6683daa17c16a508a51f6a7e7314018dab8fdc49b@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-24 17:40:23 -07:00
ss-dev-00
105a3654d6 Merge remote-tracking branch 'origin/main' into ss-dev-00/agent-activity-cover
* origin/main:
  feat(desktop): simplify the message action rail (#6529)
  fix(desktop): restore icon-only remote marker (#6491)
  fix(ci): prevent poisoned Rust caches (#6618)
  docs(security): route reports through private advisories (#6728)
  fix(composer): wrap Buzz chip labels without orphaning icons (#6581)

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
2026-08-24 17:21:57 -07:00
ss-dev-00
d833c54ee6 fix(desktop): release Escape from a cover surface that is animating out
**Category:** fix

Replacing one cover drawer with another swallowed the next Escape, so leaving
the drawer that just arrived took two presses. `AnimatePresence` holds the
replaced surface mounted through its exit animation (~210ms measured), and for
that whole window two surfaces are listening for the key — with the outgoing
one, registered first, winning it.

Both layers had to stand down, which is why fixing one was not enough:

- `CoverDrawer`'s capture-phase claim consumes the press with
  `stopImmediatePropagation`, invisibly to the successor.
- `useEscapeKey` ignores an event that is already `defaultPrevented`, so an
  exiting *panel*'s `preventDefault` swallows the press from its successor's
  panel just as completely. This is the path agent activity actually takes: it
  sets `ownsEscape={false}` and routes the key through its panel, so no
  `CoverDrawer` Escape code runs at all.

Both now gate on `useIsPresent`. Presence rather than the focus slot is
deliberate: the slot is claimed only by a surface that captures focus, and a
successor whose content takes focus instead never claims it — leaving the
outgoing claim current, so a slot check passes for exactly the surface that
must stand down. That version was tried and falsified in the browser. Outside
`AnimatePresence` there is no presence context and `useIsPresent` is `true`, so
the many panels that never animate out are unaffected; `useEscapeKey` also
keeps its `escapeSurfaces` registration for the exit's duration, so background
shortcuts still yield.

Tests, each falsified by removing the guard it covers rather than assumed:
two jsdom cases drive the real primitives under a real `AnimatePresence` with a
keyed child swap, one per layer, asserting the overlap is live before pressing;
a third pins the no-presence-context case. An e2e case presses once inside the
window, reaching activity by navigation so no dismissable layer of the test's
own can absorb it. Removing either guard fails exactly the new coverage.

The visual half of the overlap — both panels cross-fading in the same window —
is unchanged here and stays open in #6734 as a design question.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 17:20:03 -07:00
morgmart
c5166f2164 feat(desktop): simplify the message action rail (#6529)
## Problem

Quick-reaction shortcuts make the message action rail visually noisy.
Reaction access should remain first-class, but through one predictable
Add reaction action rather than several learned emoji shortcuts.

## Change

The rail is now:

**Add reaction · Reply · Copy link · More**

- Remove all quick-reaction shortcut buttons and their divider from the
message action rail.
- Preserve Add reaction as the first action, including its existing
picker, tooltip, accessible name, keyboard behavior, reaction behavior,
and feedback.
- Keep the existing Link2 Copy link action, shared copy handler,
More-menu entry, eligibility guards, and success feedback unchanged.
- Keep reveal, positioning, responsiveness, styling, and remaining menu
paths unchanged.

The now-unused quick-reaction rendering component and imports were
removed from `MessageActionBar`; shared reaction learning remains intact
for other reaction surfaces.

## Tests

Focused smoke coverage verifies:

- zero `React with …` shortcut buttons;
- exact ordered rail: `Open reactions → Reply → Copy link → More
actions`;
- the rail and More-menu paths emit the same canonical thread-aware
`buzz://message` URL;
- existing success feedback;
- pending and huddle rows omit both copy-link surfaces;
- the action bar stays within the open thread panel.

The zero-shortcut contract was mutation-checked by restoring the prior
production action bar: the focused test failed causally with expected 0
versus received 3 quick-reaction buttons.

## Validation

At `4f062e0d60b0f0c16b6862cdea7137c287060947`:

- `pnpm exec biome check src/features/messages/ui/MessageActionBar.tsx
tests/e2e/message-copy-link.spec.ts` — passed.
- `pnpm exec tsc --noEmit` — passed.
- `pnpm test` — 5,432 passed, 0 failed.
- `pnpm build:e2e` — passed; existing dynamic-import and chunk-size
warnings only.
- `pnpm exec playwright test tests/e2e/message-copy-link.spec.ts
--project=smoke` — 2 passed.
- Pre-push `file-size-check`, `desktop-check`, `desktop-typecheck`, and
`desktop-test` — passed.

Vogue’s design review: **SHIP** — reaction remains discoverable and
accessible as the visible first action; the extra click is an
intentional efficiency tradeoff for the simpler hierarchy.

---------

Signed-off-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
Co-authored-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
Co-authored-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
2026-08-24 16:28:51 -07:00
ss-dev-01
fd2e017998 fix(desktop): hide the decorative identity avatar and split the conversation suite
Two review findings on #6720.

**P2 (Carl, CHANGES_REQUESTED): the identity row announced the agent
twice.** `UserAvatar` names itself — an `<img alt="${displayName} avatar">`
or its fallback initials — and the row puts the same name in visible text
immediately after it, so assistive tech read the identity twice for every
agent turn in the transcript. Wrap the avatar in an `aria-hidden` span so
the visible name is the row's single accessible identity.

Marked at this call site rather than by teaching the shared `UserAvatar` a
decorative mode: other rows pair an avatar with adjacent name text and
would want the same treatment, but changing the shared component's
accessible name affects all 45 of its call sites and is not this PR's
scope.

Verified in Chromium's real accessibility tree over CDP, not from the DOM
attributes, through the channel-opened flow:

  pre-fix   role "image", name "Observer Agent avatar", ignored false
  post-fix  ignored true, ignoredReasons ["ariaHiddenSubtree"]

Geometry is unchanged across the two (row 114x20, avatar 20x20, label
90x16, 4px gap), so moving `shrink-0` onto the wrapper is not a visual
change. The new test is non-vacuous by two mutations: removing the
decorative marking fails it and only it, and re-adding a name-bearing
`aria-label` outside the hidden subtree fails its escape assertion with
the offending element named.

**P1 (Codex): the conversation test file exceeded the 1000-line
ceiling.** It reached 1253 lines. It slipped past `file-size-check`
because that script only scans `.ts`/`.tsx` under `src/features`, so the
gate I reported as green never covered this file — the rule in AGENTS.md
plainly does. Split into:

  AgentSessionTranscriptList.conversationHarness.mjs      574  (shared)
  AgentSessionTranscriptList.conversation.test.mjs        521
  AgentSessionTranscriptList.conversationChrome.test.mjs  274

The shared jsdom setup, order-sensitive TZ/locale pins, fixtures and
render helpers live in a non-test harness module, following the existing
`observedUnreadTestHarness.mjs` precedent — duplicating those pins into
two files is exactly how they drift. Test bodies are moved verbatim; a
name-by-name diff against the previous head confirms zero tests lost and
exactly one added. Gate script untouched, per the routing of its `.mjs`
coverage gap to a separate follow-up.

Full desktop suite 5432 passing / 0 failing, including the byte-for-byte
`default`/`compactPreview` fixture.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 15:45:47 -07:00
Wes
30d2fc52f9 fix(desktop): restore icon-only remote marker (#6491)
## Summary

- restore the agreed icon-only cloud marker for an agent from another
setup
- retain the accessible `From another Buzz setup` label and native hover
title
- keep the merged loading, hover/focus persistence, and exact-pubkey
routing safeguards unchanged
- update the duplicate-agent E2E to require an icon with no visible
marker text in both autocomplete and Channel members

## Why

PR #6401 accidentally changed the agreed compact icon treatment into a
visible `Other setup` badge during review hardening. This is the
smallest correction and is intended for the active release.

## Testing

- Desktop JS: 5,280/5,280 passed
- Desktop TypeScript typecheck passed
- E2E build passed
- focused duplicate-agent Playwright journey: 1/1 passed
- file-size gate passed
- changed-file Biome and `git diff --check` passed

Carl, an automated reviewer, opened this via Wes's GitHub account.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-24 16:24:43 -06:00
Wes
69b1225923 fix(ci): prevent poisoned Rust caches (#6618)
## Summary

- roll every `Swatinem/rust-cache` use back from v2.9.2 to the last
known-good v2.9.1 digest
- give Unit Tests a new `sherpa-cache-v1` key so it cannot restore the
existing poisoned artifact
- pin Renovate to v2.9.1 and add CI contracts that reject unsafe cache
actions or a misplaced generation key

## Why

After #5441 upgraded rust-cache to v2.9.2, warm-cache `main` Unit Tests
runs began failing while linking `buzz-voice` with `could not find
native static library sherpa-onnx-c-api`. The failed run at `db5617dd1`
restored the same 1.4 KB cache generation that had already failed at
`01091c15a`; the preceding cold run at `26f4c3ed3` downloaded sherpa
1.13.4 and passed.

v2.9.2 changed target cleanup, while `sherpa-onnx-sys` treats its
prebuilt `lib/` directory as proof that the native archive exists.
Rolling back the action and invalidating the affected key removes both
sides of that failure state without disabling target caching.

## Validation

At `6da0037a0407fc498cd482fcbbb74c7a15907e9f`:

- `scripts/test-rust-cache-contract.sh`
- `scripts/test-rust-cache-contract-regressions.sh`
- negative fixtures reject a bad digest in a newly named `.yaml`
workflow and a generation key moved outside the cache action's `with`
block
- YAML parse for all workflows
- release, desktop candidate, mobile release, mobile candidate, and
mobile worktree source contracts
- `just file-size-check`
- pre-commit and pre-push hooks

The PR Unit Tests run proves the cold-cache path because pull requests
restore but do not save Rust caches. The first successful `main` run
after merge will save the new Unit Tests key; the following `main` run
will exercise the warm restore.

## Related issue

None found.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-24 16:21:31 -06:00
Jordan Mecom
02dc49f0e6 docs(security): route reports through private advisories (#6728)
Routes security researchers to GitHub's private vulnerability reporting
workflow instead of a public issue or email-first disclosure.

- Makes the private advisory form the primary path in `SECURITY.md`,
with email retained as a fallback.
- Adds private-reporting links to the contributor guide, issue chooser,
and bug template.

Checked with `git diff --check`, Ruby YAML parsing, and confirmation
that private vulnerability reporting is enabled for `block/buzz`.

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-08-24 15:07:08 -07:00
ss-dev-01
e8709554a3 fix(desktop): resolve the focus identity row agent through the profiles lookup
The conversation variant's identity row read the raw `agentAvatarUrl` and
`agentName` props. The primary channel-opened flow passes
`agentAvatarUrl: null`, because `ChannelAgentSessionAgent`
(useChannelAgentSessions.ts:21-29) carries no avatar field at all — so
every channel-opened focus session showed initials in the identity row
while the panel header directly above it showed the real profile avatar.
The two disagreed about the same agent on the same surface.

Resolve profile-first out of the `profiles` lookup the panel already
hands down, exactly as `ToolItem` (ToolItem.tsx:45-52) and the panel
header (AgentSessionThreadPanel.tsx:243-249) already do. The props stay
as the fallback rather than being replaced: a locally managed agent can
hold an avatar its relay profile never published.

Found by ss-bugs-02's bug pass on #6720.

Three tests: the profile avatar wins over a null prop (the channel path),
the profile display name wins over a stale prop name, and the caller's
avatar survives when the lookup has none. Proven non-vacuous by three
mutations — reverting the resolution fails exactly the two resolution
tests, dropping the `?? agentAvatarUrl` fallback fails only the fallback
test, and removing the `LoadedImageStub` fails both avatar tests.

That stub is load-bearing, not masking: Radix `AvatarImage` renders
nothing until its own preloader reports `loaded`, and jsdom never
fetches, so both avatar assertions would otherwise have passed
vacuously against the initials fallback — the very bug under test. It
only affects avatars that have a url, so the byte-for-byte
`default`/`compactPreview` baseline fixture stays byte-identical.

Verified empirically through the real channel-opened flow in a Chromium
build, not just jsdom. Pre-fix the row rendered `hasImg: false` with an
"OA" initials fallback while the header showed the real image; post-fix
it renders the decoded `<img>` and the resolved name. That probe harness
is untracked and not in this commit.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 15:01:25 -07:00
Taylor Ho
f79d346a17 fix(composer): wrap Buzz chip labels without orphaning icons (#6581)
**Category:** fix
**User Impact:** Long Buzz link chips now wrap without overflowing in
composers and sent messages; icon-bearing stubs use at most five
graphemes when no earlier separator exists, so some sent chips attach
the icon to a shorter prefix than before. Labels over 48 graphemes are
visibly truncated in the composer while their full identity remains
available to assistive technology and in the tooltip.

**Problem:** Long repository, issue, pull request, and channel chip
labels could orphan their icon or overflow narrow composers and sent
messages. **Solution:** Keep the icon with a bounded, grapheme-safe
leading fragment while allowing the remaining text to break anywhere;
cap visible labels at 48 graphemes without changing the full tooltip or
accessible identity.

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

**desktop/src/features/messages/lib/composerMessageLinkNode.ts**
Splits composer chip content into an icon-bearing leading fragment and a
freely wrapping remainder while preserving the semantic label and link
metadata.

**desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs**
Updates renderer assertions for the fragment structure and verifies
every supported Buzz link kind retains the intended visible label.

**desktop/src/shared/styles/globals/composer.css**
Keeps ordinary composer mention decorations inline while relying on the
existing shared markdown chip wrapping rules for Buzz links.

**desktop/src/shared/ui/mentionChip.ts**
Centralizes grapheme-aware leading-fragment boundaries and label
truncation so composer and sent chips share the same visible identity.

**desktop/src/shared/ui/markdown/BuzzLinkChip.tsx**
Uses the shared grapheme-aware boundary when rendering sent-message chip
fragments.

**desktop/tests/e2e/navigation.spec.ts**
Covers increasing wrap depth across constrained widths, icon attachment,
the sent-message wrap, accessible labeling, and tooltip positioning over
both edge fragments.

</details>

## Reproduction steps

1. Open a desktop channel and paste a Buzz link with a long repository
or channel name into the composer.
2. Narrow the composer until the chip spans two or more lines.
3. Confirm the label breaks mid-string while the icon remains attached
to the first label fragment.
4. Send the message, hover both the first and last rendered fragments,
and confirm the tooltip follows the hovered fragment.

## Screenshots

**Before — the icon drops onto a separate line from its chip label**

![Composer chip with an orphaned icon before the
fix](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/00-before-inline-chip-icon-wrap.png)

**After — the icon stays attached while the remaining label wraps**

The same long repository chip at three composer widths. Its label gains
line breaks as space contracts, while the icon remains attached to the
leading fragment.

**420px — one line**

![Long repository chip on one line at
420px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/01-wide-420px.png)

**210px — two lines**

![Long repository chip on two lines at
210px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/02-medium-210px.png)

**150px — three lines**

![Long repository chip on three lines at
150px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/03-narrow-150px.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-24 14:55:05 -07:00
ss-dev-00
0d7b21b356 refactor(desktop): extract the channel agent session surface from ChannelPane
**Category:** refactor

The file-size ratchet grandfathers `ChannelPane.tsx` above the 1000-line cap
but forbids growth, and `#6575` landed it at 1011 lines on main — so this
branch's +35 became a hard gate failure on rebase rather than the +35 it was
when the branch was cut.

Extracting rather than trimming, and specifically extracting *this branch's*
own additions rather than anything `#6575` just added: the agent-session
branch of the auxiliary-surface chain moves into
`ChannelAgentSessionSurface`, taking the cross-channel re-scoping rule, the
cover-drawer-vs-split-pane choice, and its layout-prop resolution with it.
`ChannelPane` keeps what it owns — the surface resolution, the shared
`AnimatePresence` key, and the split pane's resize affordances, which it
passes down as `wrapSplitPane`.

No behaviour change: the same panel with the same props in both
presentations. `ChannelPane.tsx` is now 1006 lines, back under the ratchet's
recorded 1011.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:54:10 -07:00
ss-dev-00
b8b94fba6b test(desktop): supply the thread edit guard to the activity exclusivity harness
**Category:** test

`#6575` made `requireThreadEditResolution` a required dependency of
`useChannelAgentSessions`, which this branch's exclusivity harness predates —
so after the rebase all five cases threw `requireThreadEditResolution is not
a function` rather than exercising last-opened-wins. Production wiring was
never affected; `ChannelScreen` supplies it from `useChannelPaneHandlers`.

Rather than stub it silently, the harness takes it as an injectable so the
guard's interaction with the replacement is itself covered: a refused open
must leave the thread and its draft exactly as they were, with no
half-applied replacement that cleared the thread before the guard turned the
activity open away. Verified to fail with the guard's early return removed.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:26:25 -07:00
ss-dev-00
80ba1f5ae0 test(desktop): cover Escape's yield to an in-progress edit in the cover drawer
**Category:** test

`#6575` gave `FocusThreadDrawer` a `hasActiveEdit` prop so a capture-phase
Escape claim yields to an edit composer inside the drawer instead of
dismissing the surface and losing the draft. Rebasing this branch moved
Escape ownership out of that file and into `CoverDrawer`, so the behaviour
now lives behind `escapeYieldsToContent` — and `#6575`'s only regression
test for it is an e2e case written against the thread drawer, which cannot
see the agent drawer or the prop boundary itself.

This adds jsdom coverage at the new seam: a press inside the drawer yields
while content owns the key, closes when it does not, and a press from
outside closes it either way, so the yield cannot wedge the drawer open.
Verified to fail on the first case with the guard removed, while both
negative controls keep passing.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:21:06 -07:00
ss-dev-00
fbc0cbf39d fix(desktop): capture the cover drawer's opener once across a StrictMode replay
The drawer captured `document.activeElement` in a layout effect with an
empty dependency list. The app root is wrapped in `React.StrictMode`, which
in development replays every effect as setup -> cleanup -> setup, so by the
second setup the drawer had already focused itself and recorded *itself* as
the element it had covered. On a real close the deferred restore then
focused a node React had detached, which is a silent no-op that drops focus
to `<body>` -- keyboard-stranded, no visible focus ring, in the exact dev
build people use the feature in.

A one-shot ref guards the capture. Refs survive the replay, so the second
setup keeps the opener the first one recorded. The flag is deliberately not
reset in cleanup: the only cleanup it sees before a real close is the
simulated one, which is precisely what it exists to ignore.

The focus-slot claim stays unconditional. Re-claiming on the replayed setup
is what makes the first cleanup's deferred restore stand down, so the
coordinator was already behaving correctly here -- only the capture was
wrong.

The existing focus-handoff tests could not see this because they render
without StrictMode, so the new case renders under it via
`render(..., { reactStrictMode: true })`. Verified to fail at the previous
head (focus lands on `HTMLBodyElement` instead of the opener) and to pass
with the guard.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:18:50 -07:00
ss-dev-00
1ff5968dae test(desktop): promote the cover drawer preview into a reference screenshot spec
The throwaway spec used to compare presentation options has now paid for
itself twice, so give it a permanent home next to the other activity
screenshot specs and register it in the smoke project.

One seeded turn with the shape a real investigation has: a `@mention` that
starts it, thinking, three file reads, a shell command, a relay post, a
step that failed, a plan, and an answer containing code. The prompt is
framed the way the harness frames it — a `[Buzz event: ...]` section with
`From:`/`Content:` lines — because `parsePromptText` reads the author
pubkey and the user-visible text out of exactly that shape; a plain text
prompt would render as an unattributed bubble and would not exercise the
header the drawer exists to make readable.

Three frames, all at the drawer's design width: the full window as it
opens (the drawer against the sliver and the scrimmed channel, which a
panel-only shot cannot show), then the expanded turn at its head and at
its tail. Expanding matters — the transcript opens folded, so the default
frame is a stack of one-line summaries that shows none of the turn's
shape. Expanded it is taller than the drawer, hence two frames.

The PNGs are the deliverable, not a pixel baseline, so the assertions are
scoped to what this slice owns: the drawer covers, the panel is mounted
inside it, there is no split resize handle, and the turn actually
rendered. Transcript structure, grouping, and styling belong to the
transcript variants being restyled in parallel and are asserted by their
own specs; asserting them here would make the reference shots fail for
reasons unrelated to the drawer. For the same reason the helpers reach
for DOM contracts rather than transcript markup — the scroller is found
by computed overflow, and folds are opened through the native `<details>`
`open` property rather than by clicking a variant-owned toggle.

Two things the helpers guard rather than assume. The expander asserts it
found at least one collapsed row, so a moved fold cannot silently turn
the expanded shots into duplicates of the collapsed one. And the
scroll-to-head polls a value read two frames after the write: the panel
is tail-anchored and expanding pins it back to the bottom, so an
immediate read would report the write instead of the outcome and the shot
would quietly become a second tail frame.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:18:50 -07:00
ss-dev-00
1afe30f650 fix(desktop): coordinate cover drawer focus with a shared slot claim
**Category:** fix
**User Impact:** Opening a thread over agent activity (or activity over a
thread) now leaves keyboard focus inside the drawer that just opened. Before,
the outgoing drawer's deferred focus restore fired after the new drawer had
already taken focus and pushed focus into the covered channel, which is
`inert` — so focus landed on `<body>` with no visible focus ring and no
keyboard path back into the visible drawer.

**Problem:** Two review findings, one root cause. `CoverDrawer` decided at
teardown whether to restore focus by calling a caller-supplied predicate that
read thread view-mode state, so a presentation-only primitive was
interpreting a specific caller's presentation, and the predicate could not
see the replacement race at all. Separately, last-opened-wins was only
demonstrated through the resolver's fixed thread-beats-agent priority; the
e2e "exclusivity" test closed one drawer before opening the other and its
final step opened nothing, so no test exercised a real open-over-open
transition or asserted the replaced surface's URL param was cleared.

**Solution:** Replace the predicate with a module-level focus slot: a drawer
claims the slot when it captures focus and restores only if its claim is
still current, so any successor's claim — from any source, with no mount
ordering assumptions — invalidates the loser's pending restore. The thread
view-mode switch releases the slot from the thread side, keeping that
decision out of the primitive while leaving plain thread close identical.
Document the resolver's priority as a stale-simultaneous-param safety net
and test the open handlers directly for both orderings, which is where the
real rule lives.

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

**desktop/src/features/channels/lib/coverDrawerFocusSlot.ts**
New single-slot coordinator: `claimCoverDrawerFocus`,
`hasCoverDrawerFocusClaim`, `releaseCoverDrawerFocus`. A monotonic
generation answers "was I superseded?" without any drawer naming its
successor.

**desktop/src/features/channels/ui/CoverDrawer.tsx**
Drops the `shouldRestoreFocusOnClose` prop. Claims the slot on focus
capture and gates the deferred restore on still holding it.

**desktop/src/features/channels/ui/FocusThreadDrawer.tsx**
No longer reads thread view-mode state to decide focus restore.

**desktop/src/features/channels/ui/useThreadViewModeSwitch.ts**
Releases the focus slot when the switch places focus itself.

**desktop/src/features/channels/lib/channelAuxiliarySurface.ts**
Documents the priority order as a safety net for stale/simultaneous params,
not the product rule.

**desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs**
Drives `CoverDrawer` in jsdom: plain close restores, a replaced drawer
leaves focus with its successor, a chain of replacements keeps only the
last, and a released slot leaves focus where the caller put it.

**desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs**
Claim/supersede/release semantics, including that claims are never reused.

**desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs**
Tests the open handlers directly in both orderings, plus the breadcrumb
that keeps the replaced thread recoverable.

**desktop/tests/e2e/agent-activity-cover.spec.ts**
Rewrites the exclusivity test as genuine open-over-open transitions with no
closed intermediate: thread over activity via a `messageId` deep link,
activity over thread via the thread composer's activity bar (the one
ingress reachable while the channel is inert). Asserts the replaced param
is cleared and exactly one overlay exists.

</details>

Note on the e2e focus assertions: they are positive checks, not the
regression guard. Because the covered channel is `inert`, a wrongly-restored
focus is silently refused and lands on `<body>` rather than visibly stealing
focus — mutating the guard to a no-op still passes e2e. The discriminating
assertions therefore live in `CoverDrawerFocusHandoff.test.mjs`, where
disabling the guard fails 3 of 4 tests while the plain-close test keeps
passing.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:18:49 -07:00
ss-dev-00
88a96d7682 feat(desktop): cover the channel with the agent activity drawer
**Category:** feat
**User Impact:** At wide viewports, opening an agent's activity inside a
channel now covers the channel content area in a right-anchored drawer
instead of squeezing into a ~380px split pane, so tool calls, diffs, and
command output are readable without resizing.

**Problem:** The agent session panel shared the thread's split
`RightAuxiliaryPane`, which is too narrow for a transcript. The focus
drawer that solves this for threads was welded to thread-specific
breadcrumb and view-mode-toggle concerns, so activity could not reuse it.

**Solution:** Extract the drawer surface into a presentation-only
`CoverDrawer` and give activity its own thin wrapper. Activity always
covers at wide viewports and offers no focus/split toggle; narrow,
overlay, and single-panel presentations are unchanged for both surfaces.
A single resolved auxiliary surface makes the two drawers mutually
exclusive by construction, so last-opened wins and two drawers can never
stack. Thread drawer behavior is unchanged.

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

**desktop/src/features/channels/ui/CoverDrawer.tsx**
New presentation-only drawer extracted from `FocusThreadDrawer`: motion,
scrim, focus capture/restore, and an `ownsEscape` opt-out for content
that already handles Escape itself.

**desktop/src/features/channels/ui/AgentActivityDrawer.tsx**
Activity's wrapper. Delegates Escape to the panel so the settings menu
dismisses first.

**desktop/src/features/channels/ui/FocusThreadDrawer.tsx**
Reduced to a `CoverDrawer` wrapper plus its thread-specific
focus-restore rule. Behavior unchanged.

**desktop/src/features/channels/lib/channelAuxiliarySurface.ts**
Resolves the one auxiliary surface and which surface, if any, covers —
the structural guarantee that only one drawer exists.

**desktop/src/features/channels/lib/agentSessionPanelPresentation.ts**
Maps presentation to the panel's layout props. Suppresses the panel's own
enter motion inside the drawer to avoid a double slide.

**desktop/src/features/channels/lib/coverDrawerLayout.ts**
Sliver width and travel distance, moved off the thread-specific module.

**desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx**
Accepts and forwards `enterMotion` (additive), which `AuxiliaryPanel`
already supported but the panel never threaded through.

**desktop/src/features/channels/ui/ChannelPane.tsx**
Renders branches off the resolved surface instead of an implicit
fall-through chain, and wraps the agent panel in the cover drawer.

**desktop/tests/e2e/agent-activity-cover.spec.ts**
Covers covering vs. splitting, drawer exclusivity, suppressed panel
motion, Escape/scrim dismissal, and unchanged narrow presentation.

**desktop/tests/e2e/activity-scope-label-screenshots.spec.ts**
Lengthens the long-name fixture so the header truncation assertion still
clamps at the wider cover-drawer width.

</details>

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 14:18:47 -07:00
ss-dev-01
4818d1cbbb fix(desktop): apply the focus code recipe to fenced human prompts too
Quality review caught a real bug in the previous commit. The
`CodeBlockVariantContext` provider was mounted inside `MessageActivity`,
which only handles assistant items — `UserMessageBubble` returns before
it. A fence inside a human prompt therefore kept the legacy 16px muted
frame, nested inside the new 12px prompt bubble.

The recipe is a property of the *surface*, not of a role: a fenced block
in a prompt should read the same as one in a reply. The provider moves up
to `AgentSessionTranscriptList`, alongside the variant and turn-meta
providers, so every descendant markdown render inherits it. It still
renders no DOM, so `default`/`compactPreview` markup stays byte-identical
and the baseline fixture is unchanged.

Added a fenced-prompt test, which the existing fenced test could not
catch because it only rendered an assistant item. Proven non-vacuous two
ways: pinning the boundary provider to `default` fails both fenced tests,
and re-adding a role-scoped `default` provider around only the user
bubble's markdown fails the new prompt test alone.

Also renamed `channel-message code blocks are untouched by the focus
recipe` to `the default transcript variant keeps the legacy code chrome`.
It renders the `default` transcript variant, not a channel message row, so
the old name claimed a contract it did not prove.

AGENTS.md gains the `assert.equal(el, null)` note: that form serializes
the matched element's whole subtree to build a failure diff and OOMs the
runner instead of printing the message, which made a genuine failure
unreadable. Use `assert.ok(x === null)`.

Verified at this tree: full desktop suite 5428 passing / 0 failing,
`tsc --noEmit` clean, `biome check` at main's baseline, px-text /
pubkey-truncation / file-size clean. Screenshots captured light and dark
by temporarily pinning `transcriptVariant="conversation"` in
`AgentSessionThreadPanel`; that pin is dev-00's slice and is not in this
commit.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 13:24:51 -07:00
Will Pfleger
f6e6617a9d fix(desktop): bound thread /query and surface load errors, not false-empty (#6447)
Long threads in the desktop app sometimes never load (stuck on a
skeleton until you close and reopen the panel), and a failed load
silently renders as "No replies in this branch yet" — presenting a
broken fetch as an authoritative empty thread with no way to recover.
This fixes both, the two IMPORTANT findings from the thread-load
investigation.

## Defect 1 — unbounded `/query` request

The shared `reqwest::Client` in `relay.rs` sets no timeout, and neither
`/query` request builder set a per-request `.timeout(...)`. A stalled or
half-open connection (headers or body never arrive) leaves the request
pending forever, so a thread-history load hangs on the skeleton
indefinitely.

Fix: a 30s per-request deadline on both `/query` builders, funnelled
through one `send_query_request` helper so the timeout can never be
applied to one builder and dropped from the other. Scoped per-request
rather than client-level because the same client also serves STT/TTS
model downloads, builderlab auth, and the media proxy — a client-level
timeout would cut those off. The deadline sits above the 25s WS
`HISTORY_TIMEOUT_MS` so a slow-but-live relay isn't cut off before the
WebSocket path would be. A timeout surfaces through
`classify_request_error` as the stable `"relay unreachable: request
timed out"` string.

`send()` resolves as soon as response headers arrive, so a relay that
returns headers and then stalls the body trips the deadline during body
consumption, not at `send()` — and that consumption happens on two
paths: `parse_json_response` for 2xx, and `relay_error_message` for a
non-success status (500/429/…). Both paths route their body-consumption
error through one shared `classify_body_timeout` helper so they can't
drift: a stalled body surfaces the stable `"relay unreachable: request
timed out"` string on either path rather than the malformed-response
bucket (2xx) or a bare `"relay returned 500"` status label (non-2xx). A
genuinely non-stalled error still keeps its status classification.

## Defect 2 — terminal error painted as empty

`ChannelScreen` consumed only `isPending`/`data` from the thread-replies
query. Once React Query exhausted its one retry, `isPending` was false
and the zero-length data fell through `selectDeferredListRenderState` to
the `"empty"` state — indistinguishable from a genuinely empty branch,
with no retry affordance.

Fix: plumb `isError` + `refetch` through `ChannelScreen` → `ChannelPane`
→ `MessageThreadPanel`. A pure `selectThreadRepliesSurface` helper
decides the paint in strict precedence — the load-bearing invariant is
that a terminal error **never** resolves to `"empty"`, and cached
replies stay visible non-destructively under a later error (the error
card only surfaces when there is nothing to show). The panel renders an
explicit "Couldn't load replies" + Retry card (testids
`message-thread-replies-error` / `message-thread-replies-retry`).

`ProjectConversationPanel` is a second producer of the same shared panel
and used to hard-code `threadRepliesPending={false}` with no
error/retry, so a failed load in a Projects conversation still painted
the false-empty. It now propagates the same
`isPending`/`isError`/`refetch` from its `useThreadReplies` query.

The multi-root `useThreadRepliesForRoots` hook (the Huddle transcript
and Projects-agent conversation surfaces) had the same gap in its
`useQueries` `combine`: it returned only `{ events, isPending }`, so a
failed reply subtree contributed zero rows and vanished. The combine is
now a pure, unit-testable `combineThreadRepliesResults` that exposes
aggregate `isError`/`error` plus a `refetch` that re-runs only the
failed subtrees. Both multi-root consumers render the shared "Couldn't
load replies" + Retry card when a subtree fails: the Projects-agent
conversation after its transcript, and the Huddle transcript as a
non-destructive banner above the timeline. `useHuddleChannelMessages`
used to read only `.events` and discard the aggregate state, so one
summarized root failing left the flattened transcript presenting as
complete; it now propagates `threadRepliesError`/`onRetryThreadReplies`
through `ChannelScreen` into `ChannelPane`, where successful rows stay
visible and `onRetry` re-runs only the failed subtrees.

The error card carries `role="alert"` so its asynchronous appearance is
announced to assistive tech — without a live region a screen-reader user
parked in the composer never learns the load failed or that Retry became
available.

## Tests

- `stalled_query_request_times_out_with_classified_error` — a loopback
server that never responds; asserts the stable classified timeout
string.
- `stalled_response_body_times_out_with_classified_error` — a loopback
server that writes valid 2xx JSON headers then stalls the body past the
deadline; asserts the classified timeout string, not the malformed
bucket.
- `stalled_error_response_body_times_out_with_classified_error` — a
loopback that writes `500` headers promising a body it never sends;
asserts the classified timeout string rather than the `500` status
label.
- `non_stalled_error_response_yields_status_message` — a promptly-served
`500` still surfaces `"relay returned 500 Internal Server Error"`,
pinning that timeout preservation is scoped to actual timeouts.
- `selectThreadRepliesSurface` — pending→skeleton, terminal error→error
(never empty), page-2 failure never empty, cached rows stay visible
under error, successful-empty→empty, retry-success→list,
streaming→pending, and huddle-transcript collapse.
- `MessageThreadReplyState` mounted test — terminal error renders the
error card (asserting `role="alert"`), never the empty card.
- `combineThreadRepliesResults` — multi-root aggregation/order, a failed
subtree surfaces the aggregate error and never drops rows, aggregate
pending, refetch re-runs only failed queries, all-success yields no
error.
- `thread-load-failure.spec.ts` (smoke E2E) — binds the real
channel-thread panel wiring: forces a terminal `get_thread_replies`
failure at the IPC boundary, asserts the error card renders (never the
false-empty) and Retry recovers.
- `project-conversation-load-failure.spec.ts` (smoke E2E) — the same
guard for the Projects conversation producer, driven through the
Projects Channels-tab row.
- `huddle-thread-load-failure.spec.ts` (smoke E2E) — the consumer-level
guard the combine unit test can't provide: drives the real Huddle wiring
(`useHuddleChannelMessages` → `ChannelScreen` → `ChannelPane`) with two
summarized roots, fails one subtree's fetch at the IPC boundary, asserts
the surviving root's reply stays visible while the retry alert surfaces,
then Retry recovers the failed subtree and clears the alert.

## Structure

To stay under the desktop file-size ratchet, `relay.rs`'s inline test
module moved to `relay/tests.rs`, and two pure pieces were extracted
from the panel: the empty/error reply cards (`MessageThreadReplyState`)
and the per-row branch-highlight derivation
(`selectThreadRowHighlight`).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v0.14.0-rc.4
2026-08-24 16:13:03 -04:00
ss-dev-01
f36b699220 style(desktop): bring the conversation variant closer to berd's recipes
dev-00's design-delta note measured the conversation variant against
berd's shipping chat surface and named three divergences that make the
focus view read as "not berd" regardless of how the rest is arranged.
This takes all three literally, on Buzz tokens rather than berd's.

**Agent identity row.** The largest gap was that agent prose carried no
attribution at all, so a reply read as unowned body text in a full-cover
view. berd labels every agent turn with a 20px round avatar plus the name
at `text-xs`, `mb-0.5`, `gap-1` (`MessageBubble.tsx:961-981`). Added for
the `conversation` variant only — the other variants' markup is pinned
byte-for-byte by the baseline fixture, and a test asserts the row does
not leak into them so a failure names the cause instead of printing a
markup diff.

**Prompt bubble.** berd's user turn is a soft fill with no border at all,
`px-4 py-2`, and a 12px radius (`MessageBubble.tsx:990`). berd's
`rounded-sm` is 12px on its own scale (`globals.css --radius-sm: 12px`),
not Tailwind's stock 2px; Buzz's `rounded-xl` is the exact equivalent, so
the radius drops from 16px rather than collapsing to a hairline. The cap
moves from `max-w-[85%]` to a fixed 640px measure mirroring berd's
`--chat-user-message-max-width` (`globals.css:615`): a percentage cap
re-wraps the prompt on every resize of the cover, while a fixed measure
holds one stable line length.

**Fenced code.** berd puts the language in a real header row above the
frame with the copy action opposite it, and frames the code at a 10px
radius on the page background behind a subtle border, with no shadow
(`ai-elements/code-block.tsx:379`, `:395`, `:528-529`). Buzz's
`rounded-lg` is `--radius: 0.625rem` — exactly berd's `rounded-[0.625rem]`.

The markdown renderer is shared with channel messages, so this recipe is
opt-in: `CodeBlockVariantContext` is read at render time by
`MarkdownCodeBlock` and provided by `MessageActivity`. A prop would have
to thread through `createMarkdownComponents`, whose component map must
stay module-stable and whose parsed-node cache keys on a variant string —
the same reason `VideoReviewMarkdownContext` already exists. A context
provider renders no DOM, so `MessageActivity` can wrap unconditionally
and `default`/`compactPreview` markup stays byte-identical. A companion
test renders the same fenced block through `default` and asserts the
original chrome (16px radius, muted fill, `pr-12`, absolutely-positioned
copy button) is untouched.

Fenced blocks need `ThemeProvider` and `TooltipProvider` to mount, so
those tests use a separate `renderTranscriptWithCodeChrome` helper; the
byte-for-byte fixture keeps rendering through the exact tree it was
captured with.

Both new code-block tests were proven non-vacuous by mutation: forcing
the provider to `default` everywhere fails only the focus-header test,
and forcing `focusProse` everywhere fails only the channel-message guard.
That guard is written as `assert.ok(x === null)` rather than
`assert.equal(x, null)` — on failure the latter serializes the matched
jsdom element and its subtree to build a diff, which exhausts memory
instead of printing the message.

Verified at this tree: full desktop suite 5427 passing / 0 failing,
`tsc --noEmit` clean, `biome check` at main's baseline, px-text /
pubkey-truncation / file-size checks clean.

Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 13:08:42 -07:00
Taylor Ho
4bf80978f5 fix(messages): route edits to the owning composer (#6575)
**Category:** fix
**User Impact:** Editing a channel message now opens in the main
composer, while editing a thread reply stays in the thread composer,
with focus ready for typing.

**Problem:** When a thread was open, Buzz treated its root message as
thread-owned and opened edits in the thread composer. Menu-driven edits
also lacked regression coverage for immediate focus.

**Solution:** Carry the message's semantic root/reply classification
into the edit target, route only actual replies to the thread composer,
and use the menu primitive's selection event for a reliable handoff.
End-to-end tests cover placement and focus for both paths.

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

**desktop/src/features/channels/ui/ChannelPane.tsx**
Routes edit targets by semantic thread ownership rather than membership
in the open thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Uses the shared composer edit-target type so routing metadata stays
attached to the target.

**desktop/src/features/messages/lib/draftMentionRefs.ts**
Classifies each edit target as a root or true thread reply from its
event tags.

**desktop/src/features/messages/lib/draftMentionRefs.test.mjs**
Covers semantic ownership for root and reply edit targets.

**desktop/src/features/messages/ui/MessageActionBar.tsx**
Handles Edit through the dropdown menu's selection event so focus
restoration and edit startup share the intended lifecycle.

**desktop/src/features/messages/ui/MessageComposer.types.ts**
Adds semantic thread ownership to the edit-target contract.

**desktop/tests/e2e/messaging.spec.ts**
Verifies root edits use and focus the main composer, while reply edits
use and focus the thread composer.

</details>

### Reproduction Steps

1. Send a channel message and open its thread.
2. From the thread panel, edit the root message; confirm its content
loads in the main composer and the editor is focused.
3. Send a reply in that thread.
4. Edit the reply; confirm its content loads in the thread composer and
the editor is focused.


### Screenshots

**Editing a channel-root message uses the main composer**

![Channel-root message editing in the main composer, dark theme with
purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-root-main-dark.png)

**Editing an actual thread reply uses the thread composer**

![Thread reply editing in the thread composer, dark theme with purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-reply-thread-dark.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-24 13:06:00 -07:00