Skip to content

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

Open
baxen wants to merge 2 commits into
mainfrom
ss-dev-00/agent-activity-cover
Open

feat(desktop): cover the channel with the agent activity drawer#6542
baxen wants to merge 2 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.

**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>
@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".

useAgentActivityDrawer ? (
<AgentActivityDrawer
channelName={activeChannel?.name ?? "channel"}
key={AGENT_SESSION_SURFACE_KEY}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize transitions between cover drawers

When activity is opened from a focus-mode thread, or its Back action restores that thread, this distinct key makes the agent drawer enter while the thread drawer with THREAD_SURFACE_KEY remains mounted for its exit animation. Because the surrounding AnimatePresence uses its default synchronous mode, two full-size z-41 overlays exist and animate simultaneously, despite the single-surface resolver; the exiting thread drawer can also continue claiming Escape during that interval. Use a shared cover-slot key or a coordinated/waiting presence transition so the replacement drawer does not mount until the previous one has left.

Useful? React with 👍 / 👎.

**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>
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