Commit Graph

1242 Commits

Author SHA1 Message Date
Rizz
a0da52a076 fix(desktop): pad focus message bubble
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-25 16:19:30 -07:00
Rizz
20f32f5a4b fix(desktop): anchor activity message hover cues
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-25 16:11:03 -07:00
Rizz
0ec8f1636d Refine focus-mode message bubbles
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>

Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-25 15:56:51 -07:00
Rizz
07344db5d8 Align focus-mode chat bubble surfaces
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>

Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-25 15:49:31 -07:00
Rizz
62b9603bac Render agent message sends as chat bubbles
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>

Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
2026-08-25 15:44:41 -07:00
Rizz
f60760c7f0 fix(desktop): preserve thread edit guards in focus preview
Co-authored-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@buzz.block.builderlab.xyz>
Signed-off-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@buzz.block.builderlab.xyz>
2026-08-25 15:31:45 -07:00
ss-dev-00
410b1ab5f4 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 15:26:12 -07:00
ss-dev-00
f46055dcfa 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-25 15:26:08 -07:00
ss-dev-00
e02cd6b62f 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-25 15:26:08 -07:00
ss-dev-00
5aae5c5bfd 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-25 15:26:07 -07:00
ss-dev-00
32c9eaf248 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-25 15:26:07 -07:00
ss-dev-00
1ca346a52b 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-25 15:26:06 -07:00
ss-dev-00
5a6dc8704f 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-25 15:26:05 -07:00
ss-dev-00
371933130e 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-25 15:26:05 -07: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
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
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
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
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
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
ss-dev-01
5e3faf443a fix(desktop): never show a trigger title as the prompt author
The conversation variant's author row resolved its name through
`resolveUserLabel({ fallbackName: item.title })`, whose last fallback is
the prompt item's title. That title describes the *trigger* that started
the turn — "@Mention", "Prompt", "Buzz event", or any title-cased Buzz
event kind — and is never an identity. On `default`/`compactPreview` it
only seeds avatar initials, so it was harmless; the conversation author
row promotes it to displayed text, so an unresolved sender was rendered
as if "@Mention" were their name.

Identity resolution for the displayed author now stops at the profile:
display name, then NIP-05 handle, then the truncated pubkey. A truncated
pubkey is terse but true, and it keeps the utterance attributed in a
full-cover view where an unlabelled bubble would read as orphaned. The
title stays available as trigger chrome in the footer.

The avatar seed keeps the old chain verbatim, so `default` and
`compactPreview` markup is untouched — the byte-for-byte fixture still
passes unchanged.

The existing author-row test was asserting the bug: its expectation came
from `item.title`, with no profile in the lookup. It now supplies a
profile and asserts the resolved display name, and the fixture's prompt
title is the realistic "@Mention" rather than a person's name so no test
can silently depend on a title reading as an identity again. Two new
tests cover the unresolved sender (truncated pubkey, and an explicit
assertion that no trigger word appears) and NIP-05-only resolution.

Non-vacuous: reverting the fix fails the unresolved-sender test — and
only that test — with `actual: '@Mention'`.

Verified at this tree: full desktop suite 5381 passing / 0 failing,
typecheck clean, `pnpm check` findings identical to main's baseline,
file-size ratchet clean. Transcript file also green under UTC/de-DE and
Asia/Tokyo/ja-JP.

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 11:45:34 -07:00
ss-dev-01
c91819706e fix(desktop): pin the transcript baseline fixture's locale too
Pinning TZ fixed one ambient input to the captured-markup fixture and missed
a second one. The session-boundary divider formats its date with a bare
`toLocaleString()` (`AgentSessionTranscriptChrome.tsx`), which is
locale-sensitive as well as zone-sensitive: the fixture holds
"6/14/2026, 7:05:00 PM", and under `LANG=de-DE` the same render produces
"14.6.2026, 19:05:00". Node takes its default locale from LANG/LC_ALL, so
the byte-for-byte comparison failed on any machine with a non-US locale —
the same class of bug as the timezone one, one variable over, and the TZ
guard passed happily while it happened.

Assigning `process.env.LANG` at runtime does not fix this: node resolves its
default locale once at startup, so unlike `TZ` (which `Date` reads lazily)
the assignment is silently ineffective. Pinning the locale means overriding
the two formatting surfaces the render path reaches — `Intl.DateTimeFormat`
constructed without an explicit locale, and `Date.prototype.toLocale*`,
which does not route through `Intl.DateTimeFormat` and needs its own patch.

`Intl.DateTimeFormat` must be replaced with a plain function, not an arrow:
the render path calls it with `new`, and an arrow function is not a constructor.

The guard assertion now covers locale and `toLocaleString` output alongside
the zone, so a future drift fails with a specific message instead of a 12KB
markup diff.

Verified: the de-DE failure reproduces before this change, and the suite
passes after it under TZ/LANG combinations UTC/en-US, UTC/de-DE,
America/New_York/ja-JP, Asia/Tokyo/de-DE, and UTC with LANG unset.

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 11:45:33 -07:00
ss-dev-01
2b7b0baf68 fix(desktop): pin the transcript baseline fixture to UTC
The captured-markup fixture embeds formatted timestamps, because
`formatTranscriptTimestampTitle` renders in the ambient zone. It was
captured at UTC-7, so the byte-for-byte comparison failed in CI (UTC) for a
reason that had nothing to do with markup: "at 12:00:01 PM" vs
"at 7:00:01 PM". This is why Desktop Core failed on the previous two heads.

Pin `process.env.TZ = "UTC"` before the transcript modules are imported —
their `Intl.DateTimeFormat` instances are module-level constants that
resolve the zone once at construction — and recapture the baseline from
pre-change main under that zone. A guard assertion in the coverage test now
fails with a specific message if the ambient zone is not UTC, rather than
dumping a 12KB markup diff.

Verified passing under TZ=UTC, TZ=America/New_York, and TZ=Asia/Tokyo.

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 11:45:33 -07:00
ss-dev-01
63c928fc8c fix(desktop): address review on the conversation transcript variant
Review follow-ups on the `conversation` transcript variant.

Variant selection is now caller-pinned only. The 640px width heuristic and
its module are gone: an automatic wide-pane mode is a separate product
decision, and swapping the whole transcript presentation as a reader drags
a resize handle across a threshold is not a decision to make implicitly.
Slice A's cover host pins `conversation` explicitly.

The thought disclosure no longer records the browser's programmatic toggle
echo as a reader choice. `<details>` fires `toggle` when React changes
`open`, so auto-opening a streaming thought was pinning it open forever and
reasoning never receded once the agent acted. Only a toggle that disagrees
with the last rendered state can have come from the reader. The regression
test injects the echo and then rerenders with a following item, and it is
the only test that fails when the guard is removed; a second test proves a
genuine reader toggle still wins.

Thought completion at the next leaf item of any kind — tool call or plan
update, not just the next assistant message — is now documented as intended
rather than incidental, with a thought -> tool -> message test pinning it.

The byte-for-byte fixture now covers the contract it claims. The baseline
transcript contains every renderable item kind (prompt with context and
setup, assistant message, thought, plan, tool, lifecycle status, error,
permission) across two sessions so a session boundary is forced, and both
legacy variants were recaptured from pre-change main. A companion test
asserts the baseline really renders each kind, so a fixture that silently
stopped covering one cannot keep passing while protecting nothing.

Also adds the plan-in-place rerender assertion: same plan id with advanced
entries retains the same card node while the progress text changes.

Known follow-up: Slice C is not merged yet, so its shared controlled-details
hook does not exist on origin and the echo guard here is a local copy with
the same semantics, flagged in a comment for replacement on rebase.

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 11:45:32 -07:00
ss-dev-01
7cec752d7a feat(desktop): add conversation transcript variant for focus-mode agent sessions
**Category:** feature
**User Impact:** In the wide/standalone agent session panel the observer
transcript now reads like a conversation — the human's prompt as a
right-aligned bubble, the agent's reply as unboxed prose at a comfortable
measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan
as a live checklist, and session/status noise receded to quiet dividers.

**Problem:** The transcript had two presentations — the dense `default`
activity feed and `compactPreview` — both tuned for a narrow panel. Read at
full width they are a scan surface, not something you read a turn in.

**Solution:** A third, purely additive `conversation` variant, threaded
through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an
optional prop with a width-based default. Tool items deliberately route
through the same `ToolActivity` presenter as `default` so the parallel
tool-chain-card work merges cleanly, and the drawer/shell is untouched.

The byte-for-byte guarantee for the existing variants is enforced by a
captured-markup fixture, not by inspection: the baseline was produced by
mounting the same transcript on unmodified code, and the test fails on any
drift in `default` or `compactPreview` output.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
2026-08-24 11:45:32 -07:00
morgmart
db5617dd15 fix(desktop): emit singular mention feed category so alerts route correctly (#6665)
## Problem

Mentions — and thread replies that @-mention you — played the **Needs
action** sound instead of the **@Mentions** sound. Reported by
@morgmart: "I set Needs action to a different sound and it's the only
one I ever hear."

## Root cause

Two vocabularies got conflated in #475:

- **Section / filter vocabulary** (plural): `mentions`, `needs_action`,
`activity`, `agent_activity` — the shape of `FeedSections`, the
`--types` filter, and the agent-facing CLI docs.
- **Per-item category vocabulary** (singular for mention): `mention`,
`needs_action`, `activity`, `agent_activity` — the `FeedItemCategory`
contract in `desktop/src/shared/api/types.ts`, unchanged since #12.

The Tauri feed builder reused the filter string `"mentions"` as each
mention item's `category`. Only one word differs between the
vocabularies, so only mentions broke. Every frontend consumer compares
against the singular, so real mentions never matched and fell through to
the resolver's `needs_action` fallback.

The E2E mock bridge emits the singular form, so tests never saw the
drift.

### Symptoms this fixes (all from the one mislabel)

- Mentions and mentioning thread replies played the Needs-action sound
- Mention notifications used the Needs-action title format
- Mentions in muted channels were suppressed (the mute-bypass never
fired)
- Inbox / Home feed labelled mentions "Channel update"
- Channel activity popover's mentions list was always empty

## Fix

**Fix the owner, not the symptoms.** `FeedItemInfo.category` becomes a
`FeedItemCategory` enum whose serde form is exactly the TS union, so a
misspelled category can't compile at the producer. A serialization test
pins each variant to its wire string.

**Frontend:** `slotForFeedKind` maps every known category explicitly.
The `needs_action` fallback for unknown categories is **kept on
purpose** — a contract drift should cost the user the wrong sound, not a
missed alert — but it now `console.warn`s so the drift is visible to
developers instead of masquerading as intended behavior. `e2eBridge.ts`
and `tauri.ts` now derive the category type from `types.ts` instead of
retyping it.

Not touched: the plural `--types` filter and `FeedSections` keys. Those
are the section vocabulary and are correct as-is.

## Verification

- `just ci` green (file-size ratchet, Rust/Tauri/desktop/mobile tests,
desktop + web builds)
- New tests: 2 Rust
(`feed_item_category_serializes_to_frontend_contract`,
`feed_item_from_event_carries_singular_mention_category`), 3 TS in
`sound.test.mjs` incl. one that feeds the old `"mentions"` string and
asserts fallback + warning
- **Runtime, dev build against the production relay:** controlled test
from an agent identity into a test channel —
- mention in channel → @Mentions sound, inbox shows "Mentioned in" ✅
(was Needs-action)
- thread reply with mention → @Mentions sound, once ✅ (was Needs-action)
- plain thread reply in the channel being viewed → silent, as designed ✅

## Reviewers

- @tlongwell-block — #475 introduced the plural category; please confirm
it wasn't intentional
- @wesbillman — owner of the original `FeedItemCategory` contract (#12)
and most of the feed builder
- @taylorkmho — owner of the sound-slot model and resolver (#968); the
fallback-with-warning shape is the part to weigh in on
- cc @klopez4212

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 10:20:53 -07:00
tulsi
0e69b3fd7c show mention counts in channel notifications (#6696)
**Category:** fix
**User Impact:** Stream and forum channels now show an accessible
numeric badge for unread mentions while mention chips remain clear in
every theme.

**Problem:** Mention notifications contributed to the app and Dock
badge, but inactive stream and forum rows only became bold, making it
difficult to see where multiple mentions were waiting. Mention styling
and generic destructive colors could also lose contrast or visual
meaning in some themes.

**Solution:** Use the same app-badge projection for non-DM channel
mention counts, while preserving regular unread bolding, thread activity
dots, DM counts, and manual unread behavior. Dedicated notification and
opaque mention-highlight tokens keep the new treatments stable and
readable across syntax themes.

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

**desktop/src/features/channels/useUnreadChannels.ts**
Projects app-badge-eligible mention and broadcast counts into stream and
forum channel rows while retaining DM-specific counting and
manual-unread semantics.

**desktop/src/features/sidebar/ui/SidebarSection.tsx**
Renders an accessible numeric notification pill on inactive non-DM
channels and preserves the thread activity dot fallback.

**desktop/src/shared/styles/globals/markdown.css**
Applies the shared opaque yellow highlight to human and agent mention
chips, including hover treatment.

**desktop/src/shared/styles/globals/theme.css**
Adds fixed notification and mention-highlight tokens with
theme-independent contrast.

**desktop/tailwind.config.js**
Exposes the notification token pair through semantic Tailwind utilities.

**desktop/tests/e2e/badge.spec.ts**
Covers aggregated mention counts, broadcasts, unchanged unread tiers,
exact accessible text, and badge contrast under an adversarial theme.

**desktop/tests/e2e/mentions.spec.ts**
Covers human and agent mention styling, hover behavior, dark mode, and
WCAG text contrast.

</details>

## Reproduction steps

1. Open a stream or forum channel, then navigate to another channel.
2. Receive two messages that mention you in the inactive channel.
3. Confirm the inactive row is bold and shows a red `2` pill matching
the two notifications added to the app or Dock badge.
4. Receive a regular channel message and confirm the row only becomes
bold, without a numeric pill.
5. Receive a reply in an interested thread and confirm the channel
retains its activity dot instead of a mention count.
6. Switch between light and dark themes and confirm human and agent
mention chips remain yellow with near-black readable text, including on
hover.

## Screenshots

Screenshots are posted in the PR discussion using immutable
repository-hosted image URLs.

Signed-off-by: tulsi <tulsi@block.xyz>
2026-08-24 11:48:48 -04:00
Matt Toohey
2f13e30e88 fix(desktop): hide selection formatting tray on composer right-click (#6683)
## Summary

Right-clicking a text selection in the message composer left the
selection formatting tray floating over the native context menu. This
suppresses the tray for the duration of the right-click interaction.

## Changes

- `SelectionFormattingTray.tsx`: a `contextmenu` listener on the editor
DOM sets a suppression ref, cancels any queued rAF reposition, and hides
the tray. Suppression clears on the next left-click `pointerdown` or
`keydown` in the editor, which reschedules a normal position update.
- `scheduleUpdate`/`updatePosition` both honor the suppression ref, so
editor `selectionUpdate`/`transaction`/`focus` events fired during the
right-click can't bring the tray back.
- Extracted `cancelScheduledUpdate` to replace the duplicated rAF-cancel
logic, and reset suppression on editor change / cleanup.
- E2E coverage in `composer-selection-formatting.spec.ts`: double-click
to select, assert the tray shows, right-click and assert the tray hides
*and* that `contextmenu` is not `defaultPrevented` (the native menu
still opens), then re-select and assert the tray returns.

## Testing

`just` pre-push gate ran green: `desktop-check`, `desktop-typecheck`,
`desktop-test` (5397 passing), `file-size-check`.

## Demo


https://github.com/user-attachments/assets/2fdfced9-6cd6-4eb2-a6df-c03164ab1c42

Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-08-24 09:39:55 -06:00
Matt Toohey
72ba987c36 fix(desktop): stabilize members dialog scrolling (#6670)
## Summary

- Keep virtualized member rows measurable by removing
`content-visibility: auto` from the measured row subtree.
- Use the member card's 60px baseline as the virtualizer estimate while
retaining deferred rendering for eager search and archived-member lists.
- Cover large rosters with a regression test that checks stable scroll
extent across the list and verifies the final member remains reachable.

### Related issue

None found.

### Testing

- `just ci`
- `pnpm -C desktop build:e2e`
- `pnpm -C desktop exec playwright test tests/e2e/channels.spec.ts
--grep 'members sidebar virtualizes large channel rosters'
--repeat-each=5`

#### Before


https://github.com/user-attachments/assets/a5fcc040-6872-4200-bcc3-7b4197a4dd23

#### After


https://github.com/user-attachments/assets/f2c97f2f-3719-4c2a-b17a-2450c6c70a55

Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-08-24 09:18:29 -06:00
Matt Toohey
17af15effa fix(desktop): keep member runtime status off the UI thread (#6445)
## Summary
- skip managed-agent runtime discovery when the members sidebar has no
local managed bots
- run runtime listing disk, process, and mutex work on Tauri’s blocking
pool
- preserve local managed-bot status and Start/Stop behavior with
positive and negative E2E coverage

Opening Add people in a human-only channel could invoke synchronous
native runtime discovery before the sidebar painted, leaving the macOS
app beachballed. Human invites do not depend on that data.

## Why
I have seen slowness opening this dialog in the UI


https://github.com/user-attachments/assets/1955eb5e-ee47-4edf-8e5c-606d11ffbc25


### Related issue
Related overlap: #4851 is a broader managed-agent lifecycle change that
includes a similar native offload. This draft is intentionally limited
to the sidebar critical path and adds the human-only query gate.

### Testing
I have verified the pause in the video goes away after this change.

- `just ci`
- `just desktop-check`
- `just desktop-test` (5,241 passed)
- `just desktop-tauri-fmt-check`
- `just desktop-tauri-clippy`
- `just desktop-tauri-test` (2,702 passed; 18 ignored)
- focused Playwright: human-only sidebar skips runtime discovery
- focused Playwright: local managed bot retains status and Stop/Start
controls

No visual styling changed, so screenshots are not applicable.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-08-24 09:02:14 -06:00
Tyler
2d280376ad perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
## Summary

Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.

**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).

Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.

**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.

**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.

**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).

### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.

### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):

- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.

### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.

At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).

Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):

- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean

At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green

CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.

Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.

---------

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>
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>
2026-08-24 10:11:07 -04:00
Max Lampert
040b203f73 perf(desktop): make the Projects surface render-cheap (#6460)
Diagnostic profiling on a large community (101 issues, 258 PRs) showed
Projects tab switches taking 2.5–3.6s, dominated by single React commits
of 0.5–1.5s and per-render recomputation — fetch work was already off
the main thread; the cost was building the UI.

### Measured: tab click → painted, per tab

| tab | before | after |
|---|---|---:|
| projects | 3,608ms | 320–580ms |
| repositories | 3,126–3,534ms | 310–410ms |
| tasks | 395–1,101ms | ~115ms |
| reviews | 322–2,603ms | ~96ms |
| activity | 597–741ms | ~148ms |

Single-commit ceiling dropped from 1,541ms to ≤200ms (growth steps
25–40ms). Fixes in profiled-cost order:

- **Profile popover body mounts only while open.** `UserProfilePopover`
carried seven query subscriptions plus interaction hooks per instance
even when closed; grids mount hundreds (five per card in people stacks,
one per row author) — measured **~40ms per card**, the dominant share of
the 1.2s card-tab commits. The always-mounted shell is now just the
Radix root + trigger; trigger markup, hover timing, and keyboard
handling are unchanged, and hover/tooltip event continuity is preserved
because the trigger never remounts.
- **Incremental row mounting.** The first 12 cards / 30 rows render in
the first commit; the rest stream in 36–60-per-frame low-priority
transitions. Grouped lists trim across group boundaries via a pure,
tested slicer; the mounted count survives in-place refetches.
- **Activity feed**: was rebuilt unmemoized on every render,
markdown-flattening every issue/PR/comment body in the community just to
sort and keep 30 items (~360+ flattens per render on the measured
community). Now memoized, and bodies stay raw until after the sort+slice
— 30 flattens, once per data change.
- **Contribution graph** (always-visible rail, so every tab paid for
it): ~180 day cells each wrapped in a Radix tooltip with per-cell Intl
date formatting per render. Now memoized, cells precomputed once per
data change, native `title` tooltips. (The activity-bar segments keep
their styled Radix tooltips — pinned by an existing spec.)
- **Rows/cards memoized with identity-stable props**: per-row selection
arrays were rebuilt per row per render (O(n²) — 258 PRs × 258-item
arrays each render) and are now hoisted and shared; people arrays derive
inside the memoized cards; the rail's stat walk over every issue/PR is
memoized.
- **`content-visibility: auto`** on cards and rows so offscreen entries
skip layout and paint; **tab switches run in a React transition** so the
click stays responsive while the new tree mounts.

Remaining known cost (out of scope): cold-entry data readiness — the
work-item and activity queries ship thousands of events to compute
counts (2–4s on a large community; see the fan-lifecycle PR). The
structural fix is a relay-side aggregate; tracked as follow-up.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-22 08:42:55 -06:00
Salman Mohammed
f99532585a refactor(acp): clarify agent prompt sections (#6501)
## Why
The ACP prompt puts a machine-specific Workspace prefix before static
base guidance and labels the user-facing agent instruction layer as the
generic System section. Because the cwd varies by launch and worktree,
leading with it reduces reusable prompt-prefix stability.

`[Workspace]` was added in [PR
#1194](https://github.com/block/buzz/pull/1194) as a defensive fix after
a broken `~/.sprout` → `~/.buzz` migration caused agents to scan `$HOME`
and trigger macOS TCC prompts. This change retains that grounding while
shrinking it to the current working directory and moving dynamic
environment context after the static Base prompt.

## What
- Emit the prompt in Base → Workspace → Agent Instructions order
- Reduce Workspace to `Current working directory: <absolute path>`
- Resolve cwd as an absolute native-platform path and preserve Windows
drive/UNC paths instead of checking for a leading `/`
- Emit Agent Instructions for persona and standalone agent instructions
across modern and legacy ACP paths
- Preserve parsing for archived observer frames that used System or the
former Workspace-before-Base order, and align the persona catalog label

## Risk Assessment
Medium-low — this changes prompt framing for every newly created agent
session. Existing archived observer frames remain parseable, and
execution still uses the same ACP working directory. Cwd resolution now
fails clearly instead of substituting `/` when the process directory
cannot be resolved.

## References
- https://github.com/block/buzz/pull/1103
- https://github.com/block/buzz/pull/1194

Generated with Codex

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
2026-08-22 08:29:41 -06:00
klopez4212
8c0f42e187 Add mobile Huddles voice MVP (#6056)
## Summary
- add foreground mobile Huddles on Android and iOS with native Opus
capture/playback, mute, speaker routing, participants, lifecycle, and
minimized drawer UI
- keep mobile Huddle cards and roster state live, including ended rooms,
relay-resolved profiles, and agents
- broadcast desktop agent TTS through the existing Huddle audio protocol

## Scope
Foreground human-to-human voice MVP only. Agent setup/transcripts,
background calling, recording, and advanced device controls remain out
of scope.

## Validation
- `just mobile-check`
- `just mobile-test` — 1,500 passed
- `just desktop-check` and `just desktop-test` — 4,957 passed
- desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15
ignored
- mobile worktree identity contract checks
- physical Pixel/iPhone behavior reviewed during development

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-22 05:28:16 -07:00
Taylor Ho
a2d8be5efa feat(desktop-messages): keep agents addressed across messages (#6315)
**Category:** new-feature
**User Impact:** Users can keep selected agents addressed across
consecutive messages without retyping their handles.

**Problem:** Repeated conversations with agents require manually typing
the same mentions on every turn, which adds friction and makes
recipients easy to omit.

**Solution:** The composer can now keep agents automatically addressed
per channel, either from the mention controls or after a successful
inline mention. Addressed agents remain visible in the toolbar, apply to
channel threads, survive send failures safely, and never cross community
boundaries.

## Changes

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

**desktop/src-tauri/src/events/message_tags.rs**  
Preserves the automatic-address marker on validated mention reference
tags.

**desktop/src/features/channels/ui/ChannelPane.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/communities/useCommunityInit.ts**  
Clears composer audience state when the active community changes.

**desktop/src/features/forum/ui/ForumComposer.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/home/ui/InboxDetailPane.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/messages/lib/agentAddressMention.d.mts**  
Defines helpers and types for marked automatic-address mention tags.

**desktop/src/features/messages/lib/agentAddressMention.mjs**  
Defines helpers and types for marked automatic-address mention tags.

**desktop/src/features/messages/lib/agentAddressMention.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/lib/applyEditTagOverlay.mjs**  
Preserves automatic-address metadata when edited message tags are
overlaid.

**desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts**
Stores the preference that keeps explicitly mentioned agents addressed
for later messages.

**desktop/src/features/messages/lib/extractMentionPersonas.ts**  
Separates persona recipients from the composer mention orchestration.

**desktop/src/features/messages/lib/persistentAgentAudience.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/lib/persistentAgentAudience.ts**  
Maintains bounded, in-memory, channel-scoped automatic agent audiences.

**desktop/src/features/messages/lib/useMentionSelection.ts**  
Centralizes mention picker selection state and agent-first selection
behavior.

**desktop/src/features/messages/lib/useMentions.ts**  
Exposes explicit picker origins and selection controls while preserving
inline mention behavior.

**desktop/src/features/messages/ui/ComposerAddressControls.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/ComposerAddressControls.tsx**  
Renders compact addressed-agent avatars and the automatic-mention
management entry point.

**desktop/src/features/messages/ui/MentionAutocomplete.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/MentionAutocomplete.tsx**  
Adds automatic-mention controls and options to the existing mention
picker.

**desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx**  
Shows which agents were automatically addressed on a sent message.

**desktop/src/features/messages/ui/MessageComposer.tsx**  
Integrates automatic audiences, picker controls, accessible feedback,
shortcuts, and send behavior.

**desktop/src/features/messages/ui/MessageComposer.types.ts**  
Defines the simplified channel audience context shared by composer
hosts.

**desktop/src/features/messages/ui/MessageComposerToolbar.tsx**  
Places automatic-address controls in the composer toolbar without
crowding narrow layouts.

**desktop/src/features/messages/ui/MessageRow.tsx**  
Displays automatic-address metadata alongside sent message content.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**  
Wires the appropriate channel, thread, inbox, or forum composer context
without leaking audiences across surfaces.

**desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.


**desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAddressMentionPulse.test.mjs**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAddressMentionPulse.ts**  
Provides success and failure animation signals for addressed-agent
controls.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**  
Coordinates adding, removing, and announcing automatically addressed
agents.

**desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts**  
Implements the platform-aware shortcut for toggling automatic
addressing.

**desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts**  
Promotes successfully sent inline agent mentions and provides a single
undoable notification.

**desktop/src/features/messages/ui/useComposerMentionPicker.ts**  
Opens the mention picker without rewriting the current draft.


**desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs**
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

**desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts**  
Merges automatic and inline recipients, marks outgoing tags, and
restores failed sends safely.

**desktop/src/features/messages/ui/useMentionSendFlow.ts**  
Merges automatic and inline recipients, marks outgoing tags, and
restores failed sends safely.


**desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts**
Removes the prior draft-text hydration approach now that automatic
audiences stay at composer ingress.

**desktop/src/features/settings/ui/AgentsSettingsPanel.tsx**  
Replaces the old global behavior with explicit composer-level
automatic-mention controls.

**desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx**  
Replaces the old global behavior with explicit composer-level
automatic-mention controls.

**desktop/src/shared/lib/keyboard-shortcuts.ts**  
Defines the user-facing automatic-address keyboard shortcut label.

**desktop/src/shared/ui/VideoReviewCommentMarkdown.tsx**  
Allows automatic-address prefixes to compose with video review
timecodes.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**  
Covers the automatic-address behavior and its failure, keyboard, layout,
or persistence boundaries.

</details>

## Reproduction Steps

1. Open a channel with one or more agents and open the mention picker
from the composer.
2. Select an agent for automatic mentions, then send several messages
without retyping the handle; confirm the agent remains in the composer
control and receives each message.
3. Mention another agent inline, send successfully, and confirm the
agent becomes automatically addressed; use the notification's Undo
action to reverse it.
4. Open a thread in the same channel and confirm the same addressed
agents are available there.
5. Remove an agent from the composer control and confirm later messages
stop addressing it.
6. Switch communities and confirm addressed agents do not carry into the
other community.

## Screenshots

All states below use the dark Buzz theme with a selected lilac accent.

### Addressed composer

Selected agents stay visible at the composer ingress without adding
handles to the draft.

![Two automatically addressed agents in the dark composer with a lilac
accent](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--01-addressed-composer.png)

### Open mention menu

The @ ingress opens the existing mention menu and shows which agents are
already addressed.

![Open mention menu with automatically addressed agents
highlighted](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--02-open-mention-menu.png)

### Mention options

The inline options pane controls whether a successful one-time agent
mention carries into later messages.

![Automatic mention options expanded above the mention
menu](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--03-mention-options.png)

### Agent settings

The same preference is available in **Settings → Agents →
Conversations**.

![Automatic agent mentions preference in the Agents settings
pane](https://raw.githubusercontent.com/block/buzz/74c2cbbd80ed630a0c6e00c420505a691bd4994a/pr-6315--04-agent-settings.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-21 22:04:53 -07:00
Taylor Ho
723affe5d1 fix(desktop): remove Buzz entity link previews (#6512)
**Category:** improvement
**User Impact:** Buzz-native project, repository, issue, and pull
request links now appear once as compact inline chips, with their
details available on hover.

**Problem:** Buzz-native entity links rendered both an inline chip and a
standalone preview card, repeating the same metadata and adding visual
noise to conversations. **Solution:** Exclude Buzz-native links from the
shared standalone-preview extractor while leaving entity parsing intact
for chip tooltips and preserving external web previews and attachment
cards.

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

**desktop/src/shared/lib/linkPreview.ts**
Stops Buzz-native preview candidates after parsing, including same-relay
git clone URLs that normalize to repository entities, while allowing
external URLs through the existing snapshot path.

**desktop/src/shared/lib/linkPreview.test.mjs**
Covers project, repository, issue, pull request, markdown-labeled,
same-relay clone, and mixed external-link extraction behavior.

**desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs**
Confirms sent messages no longer merge a standalone Buzz entity card
while external sender snapshots still render.

</details>

## Reproduction steps

1. Open a desktop channel containing a `buzz://project`, `buzz://repo`,
`buzz://issue`, or `buzz://pr` link.
2. Confirm the link renders as an inline entity chip without a second
standalone Buzz card below the message.
3. Hover the chip and confirm its entity metadata remains available.
4. Post an external HTTPS link and confirm its web preview still
renders.
5. Paste a same-relay `/git/<owner>/<repo>` clone URL and confirm it
uses the repository chip without a duplicate card.

## Screenshots

| Before | After |
| --- | --- |
| Inline chip plus redundant standalone Project card | Inline chip is
now the sole presentation |
| ![Before: project chip and duplicate standalone
card](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--before.png)
| ![After: project chip without a standalone
card](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--after.png)
|

**After — rich metadata stays available on hover**

![After: dark theme with pink accent and Project tooltip showing
description and repository
count](https://raw.githubusercontent.com/block/buzz/7e1a0d6cfb52382ef636d7331cf30ea334429c4d/pr-6512--after-tooltip-rich.png)

## Verification

At commit `3fa74cdd342ac1f6721b7d56a7f111af31e0e6e9`:

- focused link-preview + Markdown unit suites — 119/119 passed
- targeted registered smoke E2E — 8/8 passed, including labeled
same-relay clone metadata, ordinary-link presentation, and in-app
navigation
- `cd desktop && pnpm exec tsc --noEmit` — passed
- `git diff --check origin/main...HEAD` — passed
- pre-push hooks — desktop check, TypeScript, and full desktop unit
suite passed

---------

Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
2026-08-21 22:04:17 -07:00
Taylor Ho
074561233e fix(composer): preserve caret when inserting mentions mid-message (#6531)
**Category:** fix
**User Impact:** Users can insert mentions earlier in a draft and
continue typing without the caret corrupting the rest of the message.

**Problem:** Caret correction ran after every document change, so typing
a mention before existing text repeatedly advanced across the mention
separator and interleaved spaces into the draft. **Solution:** Limit
correction to the autocomplete settlement it was designed for, with
transaction-level and browser-level regression coverage for known and
unregistered mentions.

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

**desktop/src/features/messages/lib/mentionHighlightExtension.ts**
Restricts trailing-space caret advancement to an armed autocomplete
settlement instead of every document change.

**desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs**
Exercises the real ProseMirror plugin state and verifies mid-draft
mention typing, unknown tokens, end-of-message typing, and
completed-mention separators.

**desktop/tests/e2e/mentions.spec.ts**
Reproduces the reported composer workflow in Chromium and covers the
same corruption path for an unregistered `@token`.

</details>

## Reproduction steps

1. Open a channel and enter `hello world` in the composer.
2. Move the caret between `hello` and ` world`.
3. Type ` @bo`, select `bob` from autocomplete, and continue typing
`abc`.
4. Confirm the composer reads `hello @bob abc world` with the caret
after `abc`.
5. Repeat with an unregistered token such as ` @zzq` and confirm the
existing text remains intact.

## Before / After

| Before | After |
| --- | --- |
| Typing after a mid-draft mention walks the caret through the existing
message. | Continued typing stays after the inserted mention. |
| ![Before: mention caret corrupts existing draft
text](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-before.gif)
| ![After: caret remains after the inserted
mention](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6531/mention-caret-after.gif)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
2026-08-21 18:48:21 -06:00
morgmart
97b1fee5c3 fix(desktop): restore true zoom by scaling the root rem (#6514)
## Summary

Follow-up to #5644. Cmd +/- had become a text-only zoom: type scaled
while rem-based padding, gaps, widths, avatars, and controls stayed
frozen, which produced cramped layouts (see [#buzz-frontend
thread](buzz://message?channel=a410ffde-c61f-416a-96e0-c296b5f5ecc9&id=1a758115cf07b00c097f6e988553908c045165325a57637519cfa7ed9c9accec)).

Root cause: #5644 introduced a virtual typography rem so the **Font
size** preference could change text without moving layout — a good
decoupling — but it also routed **Cmd +/- zoom** through that same
px-valued token and pinned the real root at 16px. One decision ("freeze
layout") was applied to two dials that shouldn't share it.

This PR gives each dial one owner and lets CSS compose them:

| Control | Changes | How |
|---|---|---|
| **Cmd +/- zoom** | Everything — true zoom | Scales the real `<html>`
font-size again (`useWebviewZoomShortcuts`) |
| **Font size preference** | Text only | Sets `data-font-size`;
`typography.css` maps it to a unitless `--buzz-type-scale`, mirroring
how density already works |

`--buzz-type-rem` becomes `calc(1rem * var(--buzz-type-scale))` —
rem-relative, so it rides on zoom automatically. Resulting text px = `16
× zoom × scale × token-ratio`. The 13 / 14 / 15px conversation contract
is unchanged at default zoom. Density and the type ramp from #5644 are
untouched.

The preference module no longer does px math or knows about zoom; the
zoom hook no longer imports the preference module. Net deletion in
production code.

## Validation

- `pnpm test` — 5,308 desktop unit tests
- `pnpm check:px-text`, `tsc --noEmit`, biome
- Playwright: `top-chrome-zoom-clearance.spec.ts` (native-chrome
clearance stays fixed under root zoom),
`inbox-refactor-screenshots.spec.ts` (zoomed row padding now asserts
`4.4px` instead of the frozen `4px`), and both `profile.spec.ts` zoom
tests (composed zoom × preference, cross-window storage reset)
- Before/after screenshots at 140% zoom in the comment below

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 17:21:36 -07:00
Taylor Ho
0e48ff2691 feat(workflows): clarify workflow setup and activation (#6470)
**Category:** new-feature
**User Impact:** Workflow authors can build filtered, runtime-aware
automations, understand them at a glance, and get a clear warning before
turning on workflows likely to run often.
**Problem:** Workflow setup exposed raw configuration without enough
help composing message templates, filtering triggers, or understanding
saved behavior; activation could also make a broadly triggered workflow
live without explaining its likely frequency.
**Solution:** Batch 3 adds local, deterministic template variables,
trigger filters, and semantic summaries, then refines cards and
activation around configured behavior and a risk-aware warning boundary.
Scheduling remains the already-shipped implementation, advanced
expressions remain lossless, and network-backed identity/message
enrichment stays in Batch 4.

| Message inputs | Trigger filters |
| --- | --- |
| Caret-aware, keyboard-accessible suggestions expose trigger-local
values and safe prior-step outputs in `send_message.text`. | Structured
conditions and validated manual IDs block invalid submission while
preserving advanced expressions. |
| ![Message variable
autocomplete](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/message-variables.png)
| ![Structured trigger
filters](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/trigger-filters.png)
|

| Workflow cards | Risk-aware activation |
| --- | --- |
| Semantic labels, channel-first hierarchy, configured reaction/action
visuals, real step stacks, and compact status controls make behavior
scannable. | Broad message and frequent schedule triggers explain the
risk before **Turn on**; narrowly scoped triggers proceed without
unnecessary ceremony. |
| ![Semantic workflow
card](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/workflow-card.png)
| ![Activation
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6470/activation-choice-v2.png)
|

## Changes

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

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**  
Separates direct card status controls from secondary actions while
retaining modal status actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**  
Adds semantic behavior, channel-first hierarchy, configured
reaction/action visuals, real subsequent-step stacks, status controls,
and reduced-motion-aware trigger feedback.

**desktop/src/features/workflows/ui/WorkflowDialog.tsx**  
Warns before activating broadly triggered workflows while allowing
narrowly scoped workflows to proceed directly.

**desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx**  
Connects structured trigger filters and template-aware step inputs while
preserving schedules, trigger transitions, and selected YAML authority.

**desktop/src/features/workflows/ui/WorkflowStepCard.tsx**  
Replaces generic labels with deterministic configured-step descriptions.

**desktop/src/features/workflows/ui/WorkflowTemplateTextarea.tsx**  
Adds caret-aware variable suggestions with keyboard navigation and focus
restoration.

**desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx**  
Adds structured local filters, validated author/message IDs, and a
lossless advanced-expression fallback.

**desktop/src/features/workflows/ui/workflowActivationWarning.ts** and
**workflowActivationWarning.test.mjs**
Classify broad message and frequent schedule triggers for contextual
activation warnings.

**desktop/src/features/workflows/ui/workflowConditionExpression.ts** and
**workflowConditionExpression.test.mjs**
Model and cover parsing, serialization, validation, and
advanced-expression preservation.

**desktop/src/features/workflows/ui/workflowDefinition.ts** and
**workflowDefinition.test.mjs**
Preserve trigger/step configuration and derive deterministic card
metadata across YAML round trips.

**desktop/src/features/workflows/ui/workflowStepDescription.ts** and
**workflowStepDescription.test.mjs**
Generate and cover local step summaries.

**desktop/src/features/workflows/ui/workflowTemplateVariables.ts** and
**workflowTemplateVariables.test.mjs**
Define and cover trigger-specific, order-bounded variables and caret
insertion.

**desktop/src/features/workflows/ui/workflowTriggerDescription.ts** and
**workflowTriggerDescription.test.mjs**
Generate and cover semantic trigger summaries without network lookups.

**desktop/tests/e2e/workflow-local-controls.spec.ts** and snapshot  
Cover filters, IDs, advanced expressions, autocomplete, activation
choices, summaries, and YAML authority.

**desktop/tests/e2e/workflow-reaction-picker.spec.ts**  
Covers configured reaction emoji in workflow nodes and summaries.

**desktop/tests/e2e/workflows.spec.ts**  
Covers risk-aware activation warnings, direct safe creation,
duplication, and card status controls.

</details>

## Reproduction steps

1. Create a message-posted workflow in **Workflows**, add a Send message
step, and type `{{trig`; verify keyboard-selectable variables insert at
the caret.
2. Configure message-text and manual ID filters; verify malformed IDs
block submission and advanced expressions survive Form/YAML transitions.
3. Create a broad message workflow; verify **Back** persists nothing,
**Keep off** saves it disabled, and **Turn on** enables it. Confirm a
narrowly triggered webhook skips the warning.
4. Inspect the saved card; verify its channel, semantic behavior,
configured actions/reaction, real step stack, and status are
understandable without opening YAML.

## Validation

Validated at exact clean head `f99503819889b95ee3c61657c5c3850aae35481e`
on base `24ec6a468ec9d0d425ee58fbfc4d416412c446ad`.

- Focused workflow regressions passed 59/60 locally; the only local miss
was a 438-pixel macOS snapshot drift, while the checked-in Linux
baseline comes from the failing CI artifact. Repository pre-push gates
and E2E build/typecheck passed.
- A broader 36-test smoke invocation had 31 passes and five unrelated
pre-existing expectation/snapshot failures, so it is not claimed as
fully green. Adversarial fixes are recorded in [round
one](https://github.com/block/buzz/pull/6470#issuecomment-5366366670)
and [round
two](https://github.com/block/buzz/pull/6470#issuecomment-5366407535).

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
2026-08-21 16:53:50 -07:00
Max Lampert
b85d680fb1 perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
Entering Projects fires a large fan: an exhaustive paginated relay
enumeration (projects/repos/tombstones), five 2,000-event work-item
queries plus assignment-operation scans, per-repo activity summaries,
and a local-repository filesystem scan. Measured on a large community
(101 issues / 258 PRs):

| query | measured cost |
|---|---|
| work-items (5 × 2,000-event REQs + assignment scans) | 3.5–3.9s |
| activity summaries | 4.1s |
| repository activity | 1.0–2.2s |
| local repository scan | 1.7s |

Two lifecycle bugs made the fan far more expensive than it needs to be:

- **Freshness windows guaranteed a full refetch on nearly every
re-entry** (60s enumeration, 30s work-items/activity, 10s local scan) —
i.e., the costs above were re-paid on almost every visit. Every local
write path already invalidates its keys explicitly (issue/PR mutations,
project creation, repo sync), so the short windows only served
remote-actor freshness. Raised to 5m/2m/2m with a 30m enumeration cache:
re-entries now paint from cache, and the fan re-runs at most every 2–5
minutes.
- **Leaving Projects left the whole fan running**, competing with the
next surface's channel fetches on the same relay connection. AbortSignal
is now threaded through the enumeration and assignment pagination loops
(optional params — behavior identical without a signal), and leaving the
surface cancels the work-items query. Deliberately NOT cancelled: the
enumeration (the always-mounted sidebar projects section observes it and
its 30m cache is valuable), repo snapshots and local scans (native work
that can't abort — cancelling would discard the finished result and
force the same clones again), and activity summaries (a single bounded
request).

Abort behavior is covered by red-first unit tests on both pagination
loops. Remaining follow-up (out of scope): the queries themselves want a
relay-side aggregate instead of shipping thousands of events to compute
counts client-side.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-21 17:40:35 -06:00
Max Lampert
b0466ac465 perf(desktop): keep the member roster off the channel-switch path (#6456)
Switching channels triggered a full-roster fetch (kind:39002 plus a
kind:0 profile batch with every member pubkey as an author) in the
common case, and several render paths walked the full roster per render.
None of this scales past a few hundred members; the product target is
10k+.

- **Members query staleTime 30s → 5min.** Every membership change the
client can observe already invalidates the key explicitly: live
join/leave system messages for the active channel, member-added/removed
notifications for the current identity, and all membership mutations —
including previously-uncovered direct write paths (moderation kick,
agent-deletion cleanup), which now invalidate through a shared helper.
The 30s window bought correctness we already had and charged a roster
fetch per switch.
- **ChannelMembersBar no longer mounts the roster query for non-DM
channels** — the count renders from the channel summary, and the
private-channel huddle gate accepts `channel.isMember` (derived from the
same kind:39002 event as the roster's self entry).
- **Roster-derived lookups are cached on roster identity**
(`rosterDerivations.ts`): role map, agent-member subset, member/bot
pubkey sets. These were rebuilt O(members) on every live message /
profile re-key. React Query's structural sharing keeps the roster
identity stable, so each derivation computes once per distinct roster.
- **Backend: the kind:0 profile join in `get_channel_members` is capped
at the first 500 members** (roster order). Members past the cap keep
`display_name: None` (UI falls back to pubkey labels and profile
caches); `role=="bot"` agent flags are roster-derived and unaffected.
Full roster pagination is the structural follow-up.
- **Composer keystroke path**: `useCanAddChannelMembers` re-scanned
channels + roster per keystroke; now memoized on data identities,
sharing the cached pubkey set.

### Measured / estimated impact

| metric | before | after |
|---|---|---|
| roster fetches while switching (live trace) | nearly every switch | ≤1
per channel per 5 min |
| roster fetch cost on the wire (live, 51-member channel) | 273ms per
fetch | amortized away |
| kind:0 `authors` filter size at 10k members | ~670KB per request (~67
B/pubkey) | capped at 500 authors (~34KB) |
| warm-switch longtask at 10k members (mock harness, 4× throttle) |
364ms | 318ms |
| per-render roster walks (role map, agent sets) at 10k members |
O(members) per live message | once per distinct roster |

Deferred deliberately: protocol-level roster pagination and removing
`memberPubkeys` from channel summaries (needs relay support).

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
2026-08-21 17:40:20 -06:00
Wes
aea0ef8df9 fix(desktop): simplify duplicate agent provenance (#6401)
## Summary

- leave the local same-name agent row unlabelled because locality is
implied
- mark only the remote identity with a compact cloud badge reading
`Other setup` in mention autocomplete and Channel members
- suppress the redundant `managed by you` text for duplicate owned
agents while retaining distinct short npubs and exact-pubkey routing

## Testing

- Desktop E2E build/typecheck: `pnpm build:e2e`
- focused Playwright smoke test: `duplicate owned agents preserve
provenance and exact pubkey selection` (1 passed)
  - visible provenance in autocomplete and Channel members
  - local identity remains unmarked
  - keyboard selection routes each exact pubkey
  - narrow 760×640 viewport containment
- focused Biome check on the changed marker and E2E files
- `pnpm check:px-text`
- `pnpm check:pubkey-truncation`
- `just file-size-check`
- `git diff --check`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-21 10:05:45 -06:00
Wes
4e3c9e619c fix(desktop): sender names in notifications + macOS click-through routing (#6427)
> Opened by the agents Brain and Pinky on behalf of @wesbillman.

Fixes two desktop notification issues (requested in Buzz channel
`desktop-notification-improvements`):

## 1. Notifications now show who sent the message

Live DM and thread-reply notifications showed only "Direct message" /
"Reply in #channel", while home-feed mention toasts already carried the
sender's name. All message-notification copy is now centralized:

- **`formatMessageNotification`**
(`notifications/lib/notificationFormat.ts`) — canonical title/body for
all five sources (mention, approval, needs-action, DM, thread reply).
Sender-first titles with neutral fallbacks, never a raw pubkey:
  - DM: `Taylor` instead of `Direct message`
- Thread reply: `Taylor replied in #ship-room` instead of `Reply in
#ship-room`
- **`useNotificationSenderName`** — synchronous cache-only lookup
(react-query users-batch entry cache → persisted label cache); a cold
miss ships the fallback title immediately and warms the cache in the
background. No toast delay, no new network machinery.
- **`buildEventNotificationTarget` / `buildFeedItemNotificationTarget`**
(`notifications/lib/target.ts`) — the click-through target payload is
built in one place instead of three hand-rolled copies.

## 2. macOS notification clicks route to the target message (#3509)

On packaged builds, clicking a notification focused the app but never
navigated. Three independent gaps lined up behind one symptom:

- **Reveal hang**: notification navigation now starts before the
best-effort `unminimize → show → setFocus` chain, so a hung native
invoke cannot gate click-through; the existing 1.5s reveal timeout
remains as a secondary guard.
- **Lost emit**: the Rust delegate queues the activation target *before*
emitting `native-notification-activated`; a lost emit stranded the
target with nothing re-draining the queue. The macOS listener now also
drains on window `focus` / `visibilitychange` — delivered by WebKit
independently of the Tauri event channel, and always produced by the
click's own foregrounding.
- **Silent no-op**: `commitNavigation` skips same-href destinations;
`goChannel` / `goForumPost` / `openSearchHit` now accept `force`, and
the notification activation handler passes it so a click always routes.
Multiple queued activations are serialized FIFO with rejection
containment so an older click cannot finish after and overwrite a newer
one. Queue teardown now aborts already-running activations as well as
pending ones; async forum-comment destination resolution rechecks
ownership before community-scoped cache writes or routing.

Diagnostic evidence from the macOS unified log (packaged v0.5.17):
delegate confirmed live in release builds (`willPresent` honored —
`(["list"])` presentations); a real click response at 09:25:06 reached
`usernoted`, the app was fronted by LaunchServices, and no navigation
followed.

## Verification

- Fixed the stale relay-backed DM dedupe expectation: exactly one toast
remains required, with sender-first title `alice` instead of channel
title `alice-tyler`
- Rebased onto `origin/main` and verified 15 focused activation/click
tests, desktop typecheck/check, file-size gate, Biome on changed files,
and `git diff --check` at `21a7e0cf5`
- Notification + navigation + AppShell.helpers unit tests: 89/89 pass
(includes new `notificationFormat.test.mjs`, `target.test.mjs`,
`desktopActivations.test.mjs`)
- Full desktop JS suite run by Pinky at f03979ea: 5133/5133 pass;
pre-push hooks (desktop-test, desktop-typecheck, desktop-tauri-checks,
rust-tests, file-size ratchet) all green on this branch
- `tsc --noEmit` and changed-file Biome checks clean; re-verified after
rebasing current `origin/main`
- Click-through on a packaged build still needs human verification —
@wesbillman, next release build is the real test.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-21 09:17:04 -06:00
thomaspblock
9b32e055fe polish(desktop): finish Projects navigation and context chrome (#6429)
## Summary

After #6396, Projects still split chrome across the workspace header, a
copy-link control, and a labeled Actions group that mixed people,
create, and metadata. This PR finishes that surface: the right-hand
context box is unlabeled actions plus a Details group, people stacks and
contribution heatmaps are gone from that box, Create review sits with
Create task, and the top chrome is terminal / chat / info with no
copy-link. Sent project context collapses to a pill, and review file
diffs keep the last good git view instead of flashing empty while
queries refetch.

This also lands the remaining navigation polish that followed Part 3:
overview and list presentation, readme and commit layout, and opening
the latest matching conversation from the Channels tab without leaving
the project.

### Related issue
N/A. Related: #6396

## Testing
- Walked Files, Tasks, Reviews, task/review detail, overview tabs, and
chrome chat vs info in the running desktop app
- Pre-push: desktop typecheck, unit tests, Tauri checks, and file-size
gate passed
- Updated Projects smoke specs for the new context groups, Create
review, chrome order, and removed copy-link control
- Merged current `origin/main`; one conflict in discussion-channel rows
kept conversation-panel navigation and took main's bounded channel-name
lookup

## Post-Deploy Monitoring & Validation
- validate Projects workspace chrome, context box, and review file diffs
in the first staging Desktop session
- healthy signals: context box shows unlabeled actions then Details,
chat toggle sits between terminal and info, review diffs stay populated
across selection changes
- failure signals: missing Create review, restored heatmap/people in the
context box, or empty Files Changed while the review is still selected;
mitigate by reverting this PR

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-21 05:45:55 -07:00
Matt Toohey
9891e64f6b fix(desktop): clarify add agents channel action (#6374)
"Create agent" button takes you to a UI to **invite** your existing
agents to a channel. Rewording the button to make this clear.

Before
<img width="481" height="251" alt="Screenshot 2026-08-21 at 1 58 40 pm"
src="https://github.com/user-attachments/assets/1b64f722-62b8-46fa-a6c4-dafcf2bdfaa7"
/>


After
(Sorry about different elements being in hover state in the screenshots)
<img width="481" height="313" alt="Screenshot 2026-08-21 at 1 58 16 pm"
src="https://github.com/user-attachments/assets/f68a9c9b-2276-4ef5-ace8-c4017977b1e8"
/>
## Summary

- Clarify that the empty-channel intro action adds existing agents to
the channel.
- Assert the exact action title and description in the existing E2E
coverage while preserving the separate Welcome create-agent flow.

### Related issue

None found.

### Testing

- `../node_modules/.bin/biome check
src/features/channels/ui/useChannelIntro.tsx tests/e2e/channels.spec.ts`
passed.
- `./node_modules/.bin/tsc && ./node_modules/.bin/vite build --mode e2e`
passed.
- The isolated Playwright smoke case `empty channel shows intro actions`
passed (1/1) after installing the repo-pinned Chromium.
- `env -u BUZZ_AGENT_PROVIDER just ci` passed.
- Screenshots not captured; this is a copy-only UI change.

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-08-21 07:07:36 -04:00
Taylor Ho
de8a2741c7 feat(desktop-messages): show compact Buzz link metadata (#6252)
**Category:** improvement
**User Impact:** Buzz links in messages now resolve into compact,
descriptive chips with predictable navigation, while tooltips use the
neutral secondary surface consistently across light and dark themes.

**Problem:** Raw Buzz-native links and opaque identifiers make message
context difficult to scan, while oversized previews disrupt the
conversation. Missing, delayed, or deleted metadata also needs a stable
presentation that does not mislead users or break navigation.

**Solution:** Keep message and issue chips metadata-independent: message
chips render only the channel name, while issue chips render only the
repository name—never fetched content/title or an event hash. Fetched
context remains available in each hover tooltip. Other native links
continue to resolve into concise inline labels, with muted secondary
fallbacks, distinct deleted states, and existing navigation paths
preserved. This also makes two explicit shared-tooltip behavior changes:

1. **Color:** shared and rich tooltips move from the primary color pair
to the neutral secondary semantic pair, including dedicated huddle-token
handling and matching rich-descendant foregrounds.
2. **Hover behavior:** tooltip roots set Radix
`disableHoverableContent`, and tooltip content is pointer-transparent,
so moving off the trigger dismisses the tooltip instead of letting the
pointer enter and keep the popover open.

Together these preserve readable contrast, authoritative project
context, and predictable hover dismissal.

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

**desktop/src/features/communities/useCommunityInit.ts**
Primes channel metadata needed to resolve native links promptly.

**desktop/src/features/messages/lib/messageLinkMetadata.test.mjs**
Covers message preview extraction, fallbacks, and deleted-message
semantics.

**desktop/src/features/messages/lib/messageLinkMetadata.ts**
Normalizes fetched message metadata into compact labels and status-aware
presentation.

**desktop/src/features/messages/ui/SentFromThreadLine.tsx**
Provides thread context to linked-message metadata resolution.

**desktop/src/shared/lib/useResolvedLinkPreviews.ts**
Exposes resolved preview state for native entity chips.

**desktop/src/shared/styles/globals/markdown.css**
Adds compact chip layout and semantic disabled-state styling.

**desktop/src/shared/styles/globals/theme.css**
Defines the semantic colors used by unavailable and deleted chips.

**desktop/src/shared/ui/markdown.test.mjs**
Exercises native-link rendering and provider-backed entity metadata
behavior.

**desktop/src/shared/ui/markdown.tsx**
Routes native markdown links through metadata-aware chip components
without changing navigation contracts.

**desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs**
Covers resolved, unresolved, private, archived, and forum channel
presentations.

**desktop/src/shared/ui/markdown/ChannelDeepLink.tsx**
Renders channel names and concise channel metadata inline with stable
fallbacks.

**desktop/src/shared/ui/markdown/MessageLinkPill.tsx**
Renders loading, ready, unavailable, DM, forum, and deleted message
states compactly.

**desktop/src/shared/ui/markdown/entityLinks.tsx**
Enriches repository, issue, and pull-request chips and uses
containing-project context in tooltips.

**desktop/src/shared/ui/markdown/types.ts**
Extends markdown runtime metadata required by native chip rendering.

**desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts**
Fetches and classifies linked-message metadata while preserving
navigable fallbacks.

**desktop/src/testing/e2eBridge.ts**
Adds deterministic metadata controls for runtime visual and navigation
tests.

**desktop/tailwind.config.js**
Makes semantic disabled colors available to chip styles.

**desktop/tests/e2e/entity-link-recipient-cards.spec.ts**
Verifies entity labels, project-aware tooltips, delayed metadata, and
navigation behavior.

**desktop/tests/e2e/navigation.spec.ts**
Verifies channel and message chip states, metadata resolution, deletion
handling, and navigation.

**desktop/src/shared/ui/tooltip.tsx**
Migrates the shared tooltip surface from the primary color pair to the
semantic secondary pair. It also sets Radix `disableHoverableContent`
and makes tooltip content pointer-transparent so leaving the trigger
dismisses the tooltip predictably.

**desktop/src/features/agents/ui/RestartDiffBadge.tsx**
Aligns restart-diff tooltip values and supporting copy with the
secondary tooltip foreground.

**desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx**
Aligns rich team tooltip descriptions, persona chips, avatars, and names
with secondary tooltip semantics.

**desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx**
Uses the matching secondary tooltip foreground for project author
metadata.

**desktop/src/features/projects/ui/ProjectCards.tsx**
Aligns repository-unavailable tooltip descriptions with the secondary
tooltip foreground.

**desktop/src/shared/styles/globals/utilities.css**
Makes huddle tooltips consume their dedicated palette directly without
misleading primary or secondary aliases.

**desktop/src/shared/styles/globals/tooltipSemantics.test.mjs**
Pins direct huddle-token consumption and rejects semantic alias drift.

**desktop/tests/e2e/tooltip-semantics.spec.ts**
Verifies simple and rich tooltip surfaces and descendant tokens in Buzz
light and Catppuccin Mocha dark themes.

**desktop/playwright.config.ts**
Registers tooltip semantic coverage in the desktop smoke suite.

</details>

## Reproduction steps

1. Run the desktop app and open a message containing Buzz links for a
channel, message, repository, issue, and pull request.
2. Confirm message links show only the channel name and issue links show
only the repository name inline, with no fetched content/title or event
hash; confirm other native links remain compact rather than rendering
raw identifiers or expanded previews.
3. Hover the chips and confirm the neutral secondary tooltip adds
concise context without duplicating the visible label.
4. Open each chip and confirm it follows the same channel, message,
repository, issue, or pull-request navigation as before.
5. Inspect links while metadata is loading, unavailable, or definitively
deleted and confirm the fallback remains compact, legible, and navigable
where appropriate.
6. In Buzz light and Catppuccin Mocha dark themes, compare a simple
channel-members tooltip and the rich Add agents team tooltip; both
should use secondary semantics with readable nested content.
7. Move the pointer from a trigger toward its tooltip; confirm the
tooltip dismisses rather than remaining open as hoverable content.

## Screenshots

The matrix below covers both the default light theme and Catppuccin
Mocha with an orange accent. Message/issue-chip rows affected by the
stable-label pivot are temporarily omitted until they can be recaptured
from the real built UI; the prior images showed the obsolete expanding
inline label.

| State | Default light | Dark · orange accent |
|---|---|---|
| Public stream channel tooltip | ![Public stream channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/01-public-stream-channel-tooltip.png)
| ![Public stream channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/01-public-stream-channel-tooltip.png)
|
| Private forum channel tooltip | ![Private forum channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/02-private-forum-channel-tooltip.png)
| ![Private forum channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/02-private-forum-channel-tooltip.png)
|
| Public forum channel tooltip | ![Public forum channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/03-forum-channel-tooltip.png)
| ![Public forum channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/03-forum-channel-tooltip.png)
|
| Channel references, including unresolved | ![Channel references,
including unresolved — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/04-unresolved-channel-reference.png)
| ![Channel references, including unresolved — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/04-unresolved-channel-reference.png)
|
| Unknown channel permalink | ![Unknown channel permalink — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/05-unknown-channel-permalink.png)
| ![Unknown channel permalink — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/05-unknown-channel-permalink.png)
|
| Archived channel tooltip | ![Archived channel tooltip — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/06-archived-channel-tooltip.png)
| ![Archived channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/06-archived-channel-tooltip.png)
|
| Repository entity | ![Repository entity — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/13-repository-entity.png)
| ![Repository entity — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/13-repository-entity.png)
|
| Pull request entity | ![Pull request entity — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/14-pull-request-entity.png)
| ![Pull request entity — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/14-pull-request-entity.png)
|
| Entity metadata fallback | ![Corrected entity metadata fallback
tooltip — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-light.png)
| ![Corrected entity metadata fallback tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-dark-orange.png)
|



### Tooltip semantic surface spot check

| Buzz light | Catppuccin Mocha dark |
|---|---|
| ![Rich team tooltip on the secondary surface in Buzz
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/buzz-team-tooltip.png)
| ![Rich team tooltip on the secondary surface in Catppuccin Mocha
dark](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/catppuccin-mocha-team-tooltip.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-20 19:01:18 -07:00