Skip to content

feat(desktop): cover the channel with the agent activity drawer - #6542

Closed
baxen wants to merge 9 commits into
mainfrom
ss-dev-00/agent-activity-cover
Closed

feat(desktop): cover the channel with the agent activity drawer#6542
baxen wants to merge 9 commits into
mainfrom
ss-dev-00/agent-activity-cover

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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 already 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. Thread drawer behavior is unchanged.

Before / after

Same viewport (1280x800), same ingress (composer activity bar -> View activity), captured from the e2e mock bridge:

  • Before: the activity pane is a ~380px split column on the right. The channel keeps the remaining ~590px, and the transcript body wraps "Mention alice in the channel to see its work here." across two lines. A resize handle separates the two.
  • After: the activity panel is a ~900px right-anchored drawer covering the channel content area, with a 72px scrim-dimmed sliver of the channel still visible on its left as the click target back. The app sidebar is never covered, there is no resize handle, and the drawer carries its own close affordance.

Screenshots are on the relay rather than inline here, since relay media needs auth and would render broken in GitHub. I posted the before/after pair in the originating Buzz thread.

Notes for review

Exclusivity is structural, not defensive. resolveChannelAuxiliarySurface resolves the one surface a channel shows, and resolveChannelCoverDrawer resolves which one covers. Because that returns a single value, two drawers cannot stack by construction — there is one covered slot and the resolved surface owns it. ChannelPane now branches off that discriminator instead of the previous implicit threadHeadMessage ? … : shouldShowThreadSkeleton ? … : selectedAgent ? … fall-through.

Escape ownership is opt-out. CoverDrawer claims Escape in the capture phase by default, which the thread drawer needs because the composer's mention autocomplete would otherwise swallow the press. The agent drawer sets ownsEscape={false} and delegates to the panel's existing useEscapeKey, so the settings dropdown dismisses on the first press and the drawer closes on the second. The e2e test asserts exactly that two-press sequence.

One real bug found while wiring this up: AgentSessionThreadPanel never accepted or forwarded enterMotion, even though AuxiliaryPanel has supported it all along. My enterMotion: false was being silently dropped by the JSX spread (spreads skip excess-property checks, so typecheck was happy), and the panel was double-sliding inside the already-animating drawer. Fixed additively, and the e2e test asserts the absence of buzz-side-panel-enter — I verified that assertion fails if the prop stops being forwarded.

One test fixture updated, and it was a genuine regression on my side: activity-scope-label-screenshots.spec.ts asserts the header agent name truncates. Its long-name fixture was calibrated to the narrow split pane (745px name in a 745px box), so at the cover drawer's 899px it no longer overflowed and the assertion went 745 > 745. Truncation CSS is untouched; I lengthened the fixture name so it still clamps at the widest presentation. Confirmed the test passes on clean origin/main and failed on my branch before the fixture change.

Follow-up seam (#6538)

Slice B adds an explicit transcriptVariant prop with a conversation variant that beats the width heuristic, and the intended contract is for this drawer to pin transcriptVariant="conversation". That variant does not exist on main yet (#6538 is still open), so it is not wired here — there is a TODO(#6538) on getAgentSessionPanelPresentation, which is the single place that needs to return the prop once it lands. Panel prop surfaces are additive only, so that follow-up is a one-liner.

Validation

Run at a877508:

  • pnpm typecheck — clean
  • pnpm lint — clean (remaining biome warnings are pre-existing files I did not touch)
  • pnpm test — 5368/5368 pass, 81 suites, 0 fail
  • Playwright smoke, targeted: agent-activity-cover (3, new), thread-focus-mode (2), activity-scope-label-screenshots (2) — 7 passed. Also ran the full channels.spec.ts alongside these plus observer-feed-screenshots — 105 passed.
  • Pre-push hooks: file-size-check, desktop-check, desktop-typecheck, desktop-test all green.

I did not run the entire smoke project in one go — it is 1160 tests on a single worker and exceeds my shell timeout, so I ran the specs covering the features I touched.

New unit coverage: channelAuxiliarySurface.test.mjs (surface + cover resolution, including "every candidate open at once still resolves to exactly one surface") and agentSessionPanelPresentation.test.mjs.

@baxen
baxen requested a review from a team as a code owner August 22, 2026 02:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a877508075

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/src/features/channels/ui/ChannelPane.tsx Outdated
@baxen
baxen force-pushed the ss-dev-00/agent-activity-cover branch from 5fbe7c6 to 7f526d9 Compare August 24, 2026 19:16
@baxen

baxen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Updated PR #6542 to 7f526d92d (force-pushed after integrating the landed transcript variant seam).

Validation at the same checked-out HEAD:

  • pnpm typecheck — pass
  • pnpm check — pass (existing repo warnings only: channelMutesStorage.test.mjs, channelStarsStorage.test.mjs, SetupStep.acpForcedGate.test.mjs, and terminal.css)
  • pnpm test — 5449/5449 pass
  • touched smoke E2E: agent-activity-cover.spec.ts, thread-focus-mode.spec.ts, activity-scope-label-screenshots.spec.ts — 7/7 pass
  • promoted cover screenshot E2E — 1/1 pass; generated three distinct PNGs
  • pre-push gates — file-size, desktop-check, desktop-typecheck, and desktop-test pass
  • git diff --check — clean

The cover drawer now pins transcriptVariant="conversation"; split, narrow, overlay, and single-panel presentations keep their existing variant behavior.

@baxen
baxen force-pushed the ss-dev-00/agent-activity-cover branch from 7f526d9 to a114340 Compare August 24, 2026 19:29
ss-dev-00 and others added 7 commits August 24, 2026 14:18
**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>
**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>
…enshot spec

The throwaway spec used to compare presentation options has now paid for
itself twice, so give it a permanent home next to the other activity
screenshot specs and register it in the smoke project.

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

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

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

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

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
…ode 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>
…er 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>
…ty 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>
…nnelPane

**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>
ss-dev-00 and others added 2 commits August 24, 2026 17:20
**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>
…ity-cover

* origin/main:
  feat(desktop): simplify the message action rail (#6529)
  fix(desktop): restore icon-only remote marker (#6491)
  fix(ci): prevent poisoned Rust caches (#6618)
  docs(security): route reports through private advisories (#6728)
  fix(composer): wrap Buzz chip labels without orphaning icons (#6581)

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
@baxen

baxen commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #6911, which consolidates the full agent-activity focus-view stack (plus tho's polish) into one PR against current main, per baxen's call. No further changes will land here.

@baxen baxen closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant