feat(desktop): agent activity focus view - #6911
Conversation
Slice A of the focus-mode agent conversation. Agent activity gets the same full-cover presentation threads already had in focus mode, because its transcript is tool calls, diffs, and command output — content a 380px side pane cannot show usefully. It deliberately offers no split/focus toggle: at any viewport wide enough for two panes it always covers. The drawer surface is extracted from `FocusThreadDrawer` into a presentation-only `CoverDrawer` that knows nothing about what it covers, so the thread drawer and the activity drawer are one surface with two contents. Motion, scrim, Escape ownership, and focus capture/restore all live there. "Which panel" and "which presentation" now resolve together in `channelAuxiliarySurface`, which is what makes the two cover drawers mutually exclusive: there is one covered slot and the resolved surface owns it. Two ordering hazards the surface has to handle, both covered by tests: - Escape during a replacement. `AnimatePresence` keeps the outgoing drawer mounted through its exit, capture-phase listeners fire in registration order, and the outgoing one registered first — so it would swallow the press via `stopImmediatePropagation` and the user would have to press Escape twice. The drawer stands down for its whole exit, gated on presence rather than the focus slot: a successor whose content takes focus never claims the slot, which would leave the outgoing claim current for exactly the drawer that should stand down. - `StrictMode` replaying the focus-capture effect. By the replayed setup the drawer has already focused itself, so an unconditional `document.activeElement` read records the drawer as its own opener and a real close focuses a detached node, stranding the keyboard on `<body>`. A one-shot ref captures the opener exactly once. Reconciled against main, where the idle auxiliary panel gained its own full-cover presentation (#6590): - `FocusThreadDrawer` keeps main's `label` prop and passes it through as `CoverDrawer`'s `ariaLabel`; the idle panel reuses the presentation with its own name, which is why the label is a prop and not the thread's constant. - `outline-hidden` carries onto the extracted surface with the reason it exists recorded — the drawer is `tabIndex={-1}` and focuses itself, so the ring is noise. - `useFocusIdleDrawer` sits outside `auxiliarySurface` on purpose: the idle panel is caller-owned, is not in the last-opened-wins set, and `idleAuxiliaryOverridesThread` lets it render ahead of a thread. It still cannot contend for the covered slot, because `shouldUseFocusIdleDrawer` requires every other candidate to be absent. - main's `mode="wait"` on the presence boundary is kept. It closes the window where two drawers are mounted together, but the drawer's presence and focus-slot guards stay: they protect the surface, not this host's choice of mode. Squashed from ss-dev-00/agent-activity-cover (#6542) at 105a365. Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
…nt sessions
Slice B. A third, purely additive `conversation` transcript variant for the
full-cover reading surface: 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.
The transcript previously had two presentations — the dense `default` activity
feed and `compactPreview` — both tuned for a 380px panel. Read at full width
they are a scan surface, not something you read a turn in.
Threaded through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as
an optional prop. Tool items deliberately route through the same
`ToolActivity` presenter as `default`, which is what lets the tool-chain card
work in slice C land on top without conflict.
The byte-for-byte guarantee for the existing two variants is enforced by a
captured-markup fixture rather than by inspection: the baseline was produced
by mounting the same transcript on unmodified code, so the test fails on any
drift in `default` or `compactPreview` output.
Three defects found and fixed while building it, each with a
non-vacuous test:
- The author row resolved its name through `resolveUserLabel`'s
`fallbackName: item.title`, whose value describes the *trigger* that started
the turn ("@Mention", "Prompt", a title-cased Buzz event kind) and is never
an identity. Harmless where it only seeded avatar initials; the conversation
author row promotes it to displayed text, so an unresolved sender rendered
as if "@Mention" were their name. Identity now stops at the profile —
display name, NIP-05 handle, truncated pubkey — and the title stays as
trigger chrome in the footer. The existing test was asserting the bug.
- The captured-markup fixture embeds formatted timestamps, so it was
zone-sensitive; pinning `TZ` then exposed that it is locale-sensitive too.
Assigning `process.env.LANG` at runtime does not fix it — node resolves its
default locale once at startup, unlike `TZ`, which `Date` reads lazily. Both
formatting surfaces the render path reaches are overridden, including
`Date.prototype.toLocale*`, which does not route through
`Intl.DateTimeFormat`. `Intl.DateTimeFormat` must be replaced with a plain
function, not an arrow: the render path calls it with `new`.
Squashed from ss-dev-01/conversation-variant (#6538) at 5e3faf4.
Co-authored-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>
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
A design-delta note measured the conversation variant against berd's shipping
chat surface and named three divergences that made the focus view read as "not
berd" regardless of how the rest was arranged. This takes all three literally,
on Buzz tokens rather than berd's.
**Agent identity row.** The largest gap: 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`. Added for
the `conversation` variant only — a test asserts the row does not leak into the
other variants, 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, `px-4 py-2`,
12px radius. berd's `rounded-sm` is 12px on its own scale, not Tailwind's stock
2px, so Buzz's `rounded-xl` is the exact equivalent. The cap moves from
`max-w-[85%]` to a fixed 640px measure mirroring `--chat-user-message-max-width`:
a percentage cap re-wraps the prompt on every resize of the cover, a fixed
measure holds one stable line length.
**Fenced code.** The language moves into a real header row above the frame with
the copy action opposite it, framed at Buzz's `rounded-lg` (`--radius: 0.625rem`,
exactly berd's value).
The markdown renderer is shared with channel messages, so the code recipe is
opt-in through `CodeBlockVariantContext` read at render time. 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 `default`/`compactPreview` markup stays byte-identical.
Three defects fixed on top of the restyle, each found by review or a bug pass:
- The code-recipe provider was mounted inside `MessageActivity`, which only
handles assistant items — `UserMessageBubble` returns before it, so a fence
inside a human prompt kept the legacy 16px muted frame nested inside the new
12px bubble. The recipe is a property of the *surface*, not of a role, so the
provider moves up to `AgentSessionTranscriptList`.
- The identity row read the raw `agentAvatarUrl`/`agentName` props, and the
primary channel-opened flow passes `agentAvatarUrl: null` because
`ChannelAgentSessionAgent` carries no avatar field — so every channel-opened
focus session showed initials in the identity row while the header directly
above it showed the real profile avatar. Now resolved profile-first out of the
`profiles` lookup the panel already hands down, with the props kept as
fallback: a locally managed agent can hold an avatar its relay profile never
published.
- `UserAvatar` names itself (`<img alt="${displayName} avatar">` or its
fallback initials) and the identity row puts the same name in visible text
right after it, so assistive tech read the identity twice for every agent
turn. The avatar is wrapped `aria-hidden`, verified in Chromium's real
accessibility tree over CDP (`ignoredReasons: ["ariaHiddenSubtree"]`), not
from DOM attributes. Marked at this call site rather than teaching the shared
`UserAvatar` a decorative mode, which would affect all 45 of its call sites.
The conversation test file had reached 1253 lines, past the 1000-line ceiling.
It slipped the `file-size-check` gate because that script only scans
`.ts`/`.tsx` under `src/features` — the rule in AGENTS.md plainly covers it.
Split into a shared non-test harness module (following the existing
`observedUnreadTestHarness.mjs` precedent, because duplicating the
order-sensitive TZ/locale pins into two files is exactly how they drift) plus
two test files, bodies moved verbatim.
AGENTS.md gains the `assert.ok(x === null)` rule earned here: the
`assert.equal(el, null)` form serializes the matched element and its entire
subtree to build a diff when it fails, which on a real transcript exhausts
memory and kills the runner with SIGKILL after ~100s — a genuine regression
becomes unreadable and looks like an OOM in unrelated code.
Squashed from ss-dev-01/berd-restyle (#6720) at fd2e017.
Co-authored-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>
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Slice C. 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 the same 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 — no future edit to the block can regress the other two variants without first moving that branch. Behaviour, mirroring berd: - Live: the block is open and the rail IS the status, so there is no header line restating it. The last three steps show in arrival order; older ones go behind an "N previous steps" disclosure, 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 — its content is either laid out or not, with no intermediate height — so 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. - Reader choice wins: once the reader toggles a block, policy stops opening and closing it. One deliberate departure from berd: 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 — 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 how a headline and chain eligibility drifted apart on an earlier attempt. The projection is closed in the type system rather than by convention, so a future `TranscriptItem` variant cannot silently wear a wrench and impossible pairs are not representable. Four defects fixed on top, each mutation-checked in isolation: - **A reader's open block was folded shut by the agent posting again.** Work blocks are derived and ided after their first step, and only the LAST assistant message is exempt from the block — so a second answer demotes the first, the runs on either side merge, the old block unmounts, and the `useState` holding the reader's expansion goes with it. The choice now lives above the block, keyed by the STEP ids it was taken on, since the steps survive regrouping. An open choice beats a folded one (hiding steps a reader asked for loses what they were reading; showing extra costs a scroll), and the choice is recorded against every step in the block — recording only the first leaves the absorbed block's stale `open` entry behind and, since open wins, the merged block could never be folded again. - **A finished block went live again between turns.** `lastTurnId` walked the display blocks, but a turn that has only emitted setup lifecycle rows classifies to zero segments and produces no block — so for the whole gap between `turn_started` and the next turn's first renderable item, "newest turn with a block" was the turn that had already ended, and its trailing item became the streaming item: a settled 6-step block re-opened, dropped to its last three steps, then folded back. Every turn, not an edge case. Liveness now comes from the newest turn id in the item stream, and a trailing item is only reported as streaming when the live turn owns it. - **The running step's pulse ignored reduced motion.** `WorkBlockRailGlyph` used bare `animate-pulse`, which no `prefers-reduced-motion` block in the app matches — all 20 are scoped to `buzz-*`/`motion-*`/`t-skel-*`. Now `motion-safe:animate-pulse`. That swap also repaired three assertions it would otherwise have disarmed: they matched `.animate-pulse` as a substring and, worse, all three ran on a folded rail with no rows, so "nothing pulses" held against a build where abandoned steps pulse forever. - **A work block whose running step had no live session never settled.** Two test-hygiene fixes worth keeping: a gap test was pinning a sequence whose `session/new` card moved `streamingItemId` on its own, so those frames passed against the pre-fix code and proved nothing (the plain no-restart path now leads); and the orphaned suite inherited React Query's default 300000ms `gcTime`, arming a five-minute timer at teardown that node:test waited out — `gcTime: 0` takes the full desktop suite from 307s to 102s, verified against a negative control rather than on the passing tree alone. Squashed from ss-dev-02/tool-chain-cards (#6536) at 91a5754. Co-authored-by: ss-dev-02 <9e916f802f7932c38e630ac6b4726f5db7ca326c0d98e9bab495309eef13fe5a@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
…rawer The last wire of the feature, and what makes the four slices above one thing: the cover drawer now renders the conversation transcript, so opening agent activity in a channel lands in the reading view rather than the dense activity feed. Resolves the `TODO(#6538)` slice A landed with. The variant is chosen by presentation, not inferred from panel width. 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 rather than on the prop, so the test proves the value survives the whole path from the resolver through `ChannelAgentSessionSurface` and the panel to the transcript. It lives in the screenshot 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> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Landing the cover drawer on current main pushed `ChannelPane.tsx` to 1003 lines, three over the 1000-line cap. The file was already over the cap on the cover drawer's original base (1010 lines, so shrinking was permitted), but main has since brought it down to 982, so it may not grow at all. Move the caller-owned idle auxiliary surface into its own component, the same split `ChannelAgentSessionSurface` already uses, which brings the pane back to 996. Behaviour-preserving: the panel and both of its presentations are byte-equivalent to the inline version, and the per-presentation React keys stay on `AnimatePresence`'s direct child rather than moving inside the new component, so the cover drawer and the split pane keep the distinct enter/exit identities main gave them. Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit 62b9603) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit 07344db) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit 0ec8f16) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit 20f32f5) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit a0da52a) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> (cherry picked from commit dad000d) Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90213ba6af
ℹ️ 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".
| for (const line of text.split(/\r?\n/)) { | ||
| const match = line.match(CHECKLIST_LINE); | ||
| if (!match) continue; |
There was a problem hiding this comment.
Preserve non-checklist plan lines
When a free-form plan mixes checklist items with explanatory text, or an ACP checklist entry contains a multiline description, every continuation/non-checklist line is discarded here. Because ConversationPlanCard switches to the parsed list as soon as any entry exists, those lines never reach its Markdown fallback, so the wide agent-activity view silently truncates valid plan details. Preserve unmatched/continuation text, or only use the structured rendering when the entire plan can be parsed without loss.
AGENTS.md reference: AGENTS.md:L11-L18
Useful? React with 👍 / 👎.
Reconciling slice A against current main took main's `mode="wait"` on the `AnimatePresence` that hosts the auxiliary surfaces, added by #6590 to serialize replacements. That was the wrong side to take: overlapping mount is load-bearing for the cover drawer. Replacing one cover surface with another must stay dismissable with a single Escape, and the guards that achieve it — `CoverDrawer`'s capture-phase claim standing down while exiting, and the exiting panel declining to `preventDefault` — only have something to coordinate while both surfaces are mounted at once. Under `mode="wait"` the outgoing drawer unmounts before the incoming one enters, the press lands on nothing, and the user needs a second Escape. `agent-activity-cover.spec.ts:349` asserts the overlap directly and failed deterministically (3/3 CI retries, reproduced locally); removing the attribute turns it green. Verified no regression in the behaviour #6590 was protecting: the drawer, focus-mode, thread and projects e2e specs pass (124 tests), and the comment now records why the attribute must not come back. Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
… variant
`observer-feed-screenshots` 09 asserted the `available_commands_update`
lifecycle row as `getByText("Commands", { exact: true })`. That located the
dense variant's separate title span. The observer feed ("View activity") is
hosted by the agent activity cover drawer, so once the pin commit made the
drawer render the `conversation` variant, a plain lifecycle status recedes to a
`ConversationDivider` whose label is the joined `title · text` — and the exact
text node stopped existing.
The row is NOT dropped: the failure screenshot shows "Commands · Commands
available: 3" rendered as the centered divider. So this is a stale assertion
against the new presentation, not lost content, and the pin is correctly scoped
— the drawer IS the reading surface here.
Replaces the bare text node with three assertions that are strictly stronger:
the lifecycle row exists, it carries `data-variant="conversation-divider"`, and
its full text is the joined label. A variant flip and a dropped detail half now
each fail, where the old assertion could only see one span. Tests 08/10 in the
same spec never caught this because their `getByText` calls are non-exact and
match the joined label by substring.
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Agent activity focus view
Turns agent activity into a full-cover, read-only conversation view for one agent in the current channel, with the detail and hierarchy needed to inspect a complete turn without leaving Buzz.
This supersedes #6542, #6538, #6720 and #6536 — per baxen's call to ship the stack as one PR. Those four are left open; ss-core-02 handles their disposition after baxen retests.
Major changes
buzz messages sendoutput distinct bubble treatments, including Open in chat navigation where a source Buzz message exists.Commits
6c9b0e2fecover the channel with the agent activity drawer105a3654dcd2037e05conversation transcript variant for focus-mode sessions5e3faf44329412d6adbring the conversation variant closer to berd's recipesfd2e0179990f526be6fold focus-mode agent work into one transcript block91a5754ad403de2621pin the conversation transcript variant to the cover drawer8cea5e15eextract the channel idle auxiliary surfaceef60f0cc1…90213ba6a(6)-xfromtho/agent-activity-focus-view@dad000df423244d317let a cover drawer replacement overlap on mountEvery file tho's polish touches is byte-identical to his tip — verified per file. His branch is based on an older main, so folding it in as a merge would have reverted main's advances; the six polish commits are cherry-picked instead, leaving the rest of his branch (a rebase of this same stack) out.
Three semantic reconciliations against main
Slice A's base has moved, so these needed real resolution rather than a mechanical apply. Each is commented in the code so a reviewer doesn't have to re-derive it:
ChannelPane.tsx— two cover-drawer owners. Kept slice A's single-covered-slot resolvers (resolveChannelAuxiliarySurface/resolveChannelCoverDrawer/useCoverDrawerPresence) and main's caller-owned idle-auxiliary cover drawer. Exactly one cover drawer stays possible: the idle panel sits outsideauxiliarySurface, is not in the last-opened-wins set, andshouldUseFocusIdleDrawerrequires every other candidate absent.useCoverDrawerPresencenow takescoverDrawer !== null || useFocusIdleDrawer, with a four-way close-handler chain preserving main'spriorityIdleAuxiliaryprecedence.CoverDrawerinherits main'soutline-hidden. The drawer istabIndex={-1}and focuses itself only to land the keyboard inside, so a ring around the whole surface is noise.mode="wait"onAnimatePresence(23244d317). I first kept it and that was wrong: overlapping mount is load-bearing for a cover drawer. Replacing one cover surface with another has to stay dismissable with a single Escape, and the guards that achieve that —CoverDrawer's capture-phase claim standing down while exiting, and the exiting panel declining topreventDefault— only have something to coordinate while both surfaces are mounted at once. Undermode="wait"the outgoing drawer unmounts before the incoming one enters, so the press lands on nothing and a second Escape is needed.agent-activity-cover.spec.ts:349asserts the overlap directly and failed 3/3 in CI until the attribute came off. feat(projects): add agent and CLI project-home support #6590 added it to serialize projects-panel replacements; verified no regression there (111 drawer/focus-mode/thread e2e + 13 projects e2e green), and tho's visually-approved tree never had it — his base predates feat(projects): add agent and CLI project-home support #6590.Also took main's side where it had advanced independently: the
useRoutedMessageEditextraction (slice A never touched routed-edit logic),ChannelGlyphreplacing bareHash, and #6900's token removal. DroppedfocusedThreadCloseRequestanduseFocusDrawerPresence(renameduseCoverDrawerPresence) after confirming no remaining callers in the tree.ChannelIdleAuxiliarySurface— why there's a refactor commitLanding the cover drawer on current main put
ChannelPane.tsxat 1003 lines against the 1000-line cap. This is a genuine ratchet violation: the file was already over on slice A's original base (1010, so shrinking was permitted), but main has since brought it down to 982, so it may not grow at all. Extracting the idle auxiliary surface — the same splitChannelAgentSessionSurfacealready uses — brings it to 996.One trap worth flagging for review: moving that JSX into a component initially moved the React
keyoffAnimatePresence's direct child. Main deliberately uses different keys per presentation ("idle-auxiliary-surface"for the cover drawer vs."idle-auxiliary-panel"for the split pane), so a single key on the wrapper would have changed exit/enter identity and broken the drawer's travel direction on a viewport change. The keys stay at theChannelPanecall site, matching the existingTHREAD_SURFACE_KEY/AGENT_SESSION_SURFACE_KEYprecedent.Verification
At
23244d3171577be12abe2f4469c1f1a4cc2551f0, read in the same shell asgit rev-parse HEAD, tree clean:tsc --noEmitclean;pnpm check(biome + px-text + pubkey-truncation) exit 0; file-size ratchet clean.check-push-org,push-head-scope,branch-skew,file-size-check,desktop-check,desktop-typecheck,desktop-test.useRetainedProjectGitViews.test.mjsfailure on his tip; it did not reproduce in either full run here.Deliberately excluded
ss-dev-00/focus-view-preview(seeded-capture spec, screenshot fixtures) — confirmed absent from the tree.$#6834 part (b), the rail's missing event-fetch fallback — gated on baxen's rail-step-vs-message call. Part (a), the classifier, is a separate main PR (fix(desktop): keep quoted literals in agent-activity send previews #6841).