diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index be15c75587d..6cb91f4d4c5 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -96,6 +96,8 @@ export default defineConfig({
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
+ "**/agent-activity-cover.spec.ts",
+ "**/agent-activity-cover-screenshots.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index e111f93ca0e..0cf787647d9 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -75,7 +75,7 @@ import {
import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
-import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest";
+import { requestCoverDrawerClose } from "@/features/channels/coverDrawerCloseRequest";
import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
@@ -846,7 +846,7 @@ export function AppShell() {
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
- onBackgroundClick={requestFocusedThreadClose}
+ onBackgroundClick={requestCoverDrawerClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
diff --git a/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs b/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs
new file mode 100644
index 00000000000..75a3bf70dbe
--- /dev/null
+++ b/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ requestCoverDrawerClose,
+ subscribeToCoverDrawerCloseRequest,
+} from "./coverDrawerCloseRequest.ts";
+
+test("cover drawer close requests reach active subscribers only", () => {
+ let calls = 0;
+ const unsubscribe = subscribeToCoverDrawerCloseRequest(() => {
+ calls += 1;
+ });
+
+ requestCoverDrawerClose();
+ assert.equal(calls, 1);
+
+ unsubscribe();
+ requestCoverDrawerClose();
+ assert.equal(calls, 1);
+});
diff --git a/desktop/src/features/channels/coverDrawerCloseRequest.ts b/desktop/src/features/channels/coverDrawerCloseRequest.ts
new file mode 100644
index 00000000000..6e8f2b705ef
--- /dev/null
+++ b/desktop/src/features/channels/coverDrawerCloseRequest.ts
@@ -0,0 +1,22 @@
+const listeners = new Set<() => void>();
+
+/**
+ * Request dismissal of the channel's open cover drawer.
+ *
+ * One channel of a channel pane is covered at a time (focus-mode thread or
+ * agent activity), so this needs no discriminator — whichever drawer is open
+ * subscribes and closes.
+ */
+export function requestCoverDrawerClose(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+/** Subscribe the active cover drawer to external dismissal requests. */
+export function subscribeToCoverDrawerCloseRequest(
+ listener: () => void,
+): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs
deleted file mode 100644
index 6f30d7ec6de..00000000000
--- a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import {
- requestFocusedThreadClose,
- subscribeToFocusedThreadCloseRequest,
-} from "./focusedThreadCloseRequest.ts";
-
-test("focus thread close requests reach active subscribers only", () => {
- let calls = 0;
- const unsubscribe = subscribeToFocusedThreadCloseRequest(() => {
- calls += 1;
- });
-
- requestFocusedThreadClose();
- assert.equal(calls, 1);
-
- unsubscribe();
- requestFocusedThreadClose();
- assert.equal(calls, 1);
-});
diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.ts b/desktop/src/features/channels/focusedThreadCloseRequest.ts
deleted file mode 100644
index 3628d707676..00000000000
--- a/desktop/src/features/channels/focusedThreadCloseRequest.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-const listeners = new Set<() => void>();
-
-/** Request dismissal of an open focus-mode thread drawer. */
-export function requestFocusedThreadClose(): void {
- for (const listener of listeners) {
- listener();
- }
-}
-
-/** Subscribe the active channel surface to focus-mode dismissal requests. */
-export function subscribeToFocusedThreadCloseRequest(
- listener: () => void,
-): () => void {
- listeners.add(listener);
- return () => listeners.delete(listener);
-}
diff --git a/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs b/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs
new file mode 100644
index 00000000000..9294d134a41
--- /dev/null
+++ b/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getAgentSessionPanelPresentation } from "./agentSessionPanelPresentation.ts";
+
+test("the cover drawer owns motion and gets standalone, opaque chrome", () => {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: true,
+ isSinglePanelView: false,
+ useSplitAuxiliaryPane: true,
+ }),
+ {
+ enterMotion: false,
+ isSinglePanelView: true,
+ layout: "standalone",
+ transparentChrome: false,
+ },
+ );
+});
+
+test("the split pane keeps docked chrome and its own enter motion", () => {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: false,
+ isSinglePanelView: false,
+ useSplitAuxiliaryPane: true,
+ }),
+ {
+ enterMotion: true,
+ isSinglePanelView: false,
+ layout: "split",
+ transparentChrome: true,
+ },
+ );
+});
+
+test("narrow viewports keep today's overlay and single-panel presentations", () => {
+ for (const isSinglePanelView of [false, true]) {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: false,
+ isSinglePanelView,
+ useSplitAuxiliaryPane: false,
+ }),
+ {
+ enterMotion: true,
+ isSinglePanelView,
+ layout: "standalone",
+ transparentChrome: false,
+ },
+ );
+ }
+});
diff --git a/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts b/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts
new file mode 100644
index 00000000000..e7380da5112
--- /dev/null
+++ b/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts
@@ -0,0 +1,57 @@
+/**
+ * `AnimatePresence` key shared by every agent activity presentation.
+ *
+ * The split pane and the cover drawer are two containers for one session, so
+ * presence is a property of the session, not of either container — crossing the
+ * viewport breakpoint changes how it is shown, not whether it is open.
+ */
+export const AGENT_SESSION_SURFACE_KEY = "agent-session-surface";
+
+export type AgentSessionPanelPresentation = {
+ enterMotion: boolean;
+ isSinglePanelView: boolean;
+ layout: "standalone" | "split";
+ transparentChrome: boolean;
+};
+
+type AgentSessionPanelPresentationOptions = {
+ /** The panel is rendered inside the agent activity cover drawer. */
+ isCoverDrawer: boolean;
+ isSinglePanelView: boolean;
+ useSplitAuxiliaryPane: boolean;
+};
+
+/**
+ * Maps channel presentation into the agent session panel's layout props.
+ *
+ * TODO(#6538): once the `conversation` transcript variant lands on main, this
+ * should also return `transcriptVariant: "conversation"` for the cover drawer
+ * and `undefined` otherwise, so the reading view is pinned by presentation
+ * rather than inferred from panel width. The variant does not exist on main
+ * yet, so the prop is deliberately not set here.
+ */
+export function getAgentSessionPanelPresentation({
+ isCoverDrawer,
+ isSinglePanelView,
+ useSplitAuxiliaryPane,
+}: AgentSessionPanelPresentationOptions): AgentSessionPanelPresentation {
+ if (isCoverDrawer) {
+ return {
+ // The drawer animates itself; a second slide inside it would compound.
+ enterMotion: false,
+ // Fills the drawer, and selects the standalone header chrome that owns
+ // its own backdrop — the drawer is not sharing the channel's header, and
+ // it has no resizable neighbour to draw a resize border against.
+ isSinglePanelView: true,
+ layout: "standalone",
+ transparentChrome: false,
+ };
+ }
+
+ return {
+ enterMotion: true,
+ isSinglePanelView: useSplitAuxiliaryPane ? false : isSinglePanelView,
+ layout: useSplitAuxiliaryPane ? "split" : "standalone",
+ transparentChrome: useSplitAuxiliaryPane,
+ };
+}
diff --git a/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs b/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs
new file mode 100644
index 00000000000..bbad854850f
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs
@@ -0,0 +1,182 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ resolveChannelAuxiliarySurface,
+ resolveChannelCoverDrawer,
+} from "./channelAuxiliarySurface.ts";
+
+const NO_SURFACE = {
+ channelManagementOpen: false,
+ hasActiveChannel: true,
+ hasProfilePanel: false,
+ hasSelectedAgent: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+};
+
+test("no candidate surface resolves to nothing", () => {
+ assert.equal(resolveChannelAuxiliarySurface(NO_SURFACE), null);
+});
+
+test("every candidate open at once still resolves to exactly one surface", () => {
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ channelManagementOpen: true,
+ hasActiveChannel: true,
+ hasProfilePanel: true,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ shouldShowThreadSkeleton: true,
+ }),
+ "channel-management",
+ );
+});
+
+test("surfaces resolve in priority order as higher ones drop away", () => {
+ const all = {
+ channelManagementOpen: true,
+ hasActiveChannel: true,
+ hasProfilePanel: true,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ shouldShowThreadSkeleton: true,
+ };
+
+ assert.equal(
+ resolveChannelAuxiliarySurface({ ...all, channelManagementOpen: false }),
+ "thread",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasThreadHead: false,
+ }),
+ "thread-skeleton",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+ }),
+ "agent-session",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasSelectedAgent: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+ }),
+ "profile",
+ );
+});
+
+test("channel-scoped surfaces need an active channel", () => {
+ const withoutChannel = { ...NO_SURFACE, hasActiveChannel: false };
+
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ channelManagementOpen: true,
+ }),
+ null,
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ hasSelectedAgent: true,
+ }),
+ null,
+ );
+ // The profile panel is identity-scoped, so it survives without a channel.
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ hasProfilePanel: true,
+ }),
+ "profile",
+ );
+});
+
+test("agent activity always covers at wide viewports, whatever the thread preference", () => {
+ for (const threadViewMode of ["focus", "split"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface: "agent-session",
+ threadViewMode,
+ useSplitAuxiliaryPane: true,
+ }),
+ "agent-session",
+ );
+ }
+});
+
+test("threads cover only in focus mode", () => {
+ for (const surface of ["thread", "thread-skeleton"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ }),
+ "thread",
+ );
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "split",
+ useSplitAuxiliaryPane: true,
+ }),
+ null,
+ );
+ }
+});
+
+test("narrow and single-panel viewports never cover", () => {
+ for (const surface of ["agent-session", "thread", "thread-skeleton"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: false,
+ }),
+ null,
+ );
+ }
+});
+
+test("split-only surfaces never cover", () => {
+ for (const surface of ["channel-management", "profile", null]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ }),
+ null,
+ );
+ }
+});
+
+test("only one drawer can cover, because only one surface resolves", () => {
+ // Thread and agent activity both requested: the surface resolution picks the
+ // thread, so the agent drawer cannot also be covering.
+ const surface = resolveChannelAuxiliarySurface({
+ ...NO_SURFACE,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ });
+ const drawer = resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ });
+
+ assert.equal(surface, "thread");
+ assert.equal(drawer, "thread");
+});
diff --git a/desktop/src/features/channels/lib/channelAuxiliarySurface.ts b/desktop/src/features/channels/lib/channelAuxiliarySurface.ts
new file mode 100644
index 00000000000..c461146ca61
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelAuxiliarySurface.ts
@@ -0,0 +1,88 @@
+import type { ThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
+
+/**
+ * The one auxiliary surface a channel shows beside (or over) its timeline.
+ *
+ * Exactly one at a time, in the fixed priority order below.
+ *
+ * This priority is a **safety net, not the product rule.** Last-opened-wins is
+ * implemented by the open handlers, which clear the competing state as they open
+ * (`useChannelAgentSessions`: `openAgentSession` clears the thread head, and
+ * `openThreadAndCloseAgentSession` clears the agent session). By the time this
+ * resolver runs, at most one candidate should normally be live.
+ *
+ * The ordering only decides cases the handlers cannot: two candidates present at
+ * once with no ordering information between them — a restored/hand-edited URL
+ * carrying both `agentSession` and a thread param, or a stale param that has not
+ * been reconciled yet. Then it picks deterministically instead of rendering two
+ * surfaces. Do not read priority as "thread beats agent" in the UX; a thread
+ * opened while activity is up wins because the handler cleared the agent
+ * session, and activity opened over a thread wins for the same reason.
+ */
+export type ChannelAuxiliarySurface =
+ | "agent-session"
+ | "channel-management"
+ | "profile"
+ | "thread"
+ | "thread-skeleton";
+
+type ChannelAuxiliarySurfaceOptions = {
+ channelManagementOpen: boolean;
+ hasActiveChannel: boolean;
+ hasProfilePanel: boolean;
+ hasSelectedAgent: boolean;
+ hasThreadHead: boolean;
+ shouldShowThreadSkeleton: boolean;
+};
+
+/** Which auxiliary surface the channel pane should render, if any. */
+export function resolveChannelAuxiliarySurface({
+ channelManagementOpen,
+ hasActiveChannel,
+ hasProfilePanel,
+ hasSelectedAgent,
+ hasThreadHead,
+ shouldShowThreadSkeleton,
+}: ChannelAuxiliarySurfaceOptions): ChannelAuxiliarySurface | null {
+ if (channelManagementOpen && hasActiveChannel) return "channel-management";
+ if (hasThreadHead) return "thread";
+ if (shouldShowThreadSkeleton) return "thread-skeleton";
+ if (hasActiveChannel && hasSelectedAgent) return "agent-session";
+ if (hasProfilePanel) return "profile";
+ return null;
+}
+
+/** A cover drawer overlays the channel content area instead of splitting it. */
+export type ChannelCoverDrawer = "agent-session" | "thread";
+
+type ChannelCoverDrawerOptions = {
+ surface: ChannelAuxiliarySurface | null;
+ threadViewMode: ThreadViewMode;
+ useSplitAuxiliaryPane: boolean;
+};
+
+/**
+ * Which surface, if any, presents as a cover drawer.
+ *
+ * Threads honour the user's view-mode preference. Agent activity does not and
+ * deliberately offers no toggle: its transcript is tool calls, diffs, and
+ * command output, which a 380px side pane cannot show usefully — so at any
+ * viewport wide enough for two panes it always covers. Narrow/overlay and
+ * single-panel viewports keep their existing presentations for both.
+ *
+ * Returning a single value is what makes the two drawers mutually exclusive:
+ * there is one covered slot, and the resolved surface owns it.
+ */
+export function resolveChannelCoverDrawer({
+ surface,
+ threadViewMode,
+ useSplitAuxiliaryPane,
+}: ChannelCoverDrawerOptions): ChannelCoverDrawer | null {
+ if (!useSplitAuxiliaryPane) return null;
+
+ if (surface === "thread" || surface === "thread-skeleton") {
+ return threadViewMode === "focus" ? "thread" : null;
+ }
+
+ return surface === "agent-session" ? "agent-session" : null;
+}
diff --git a/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs b/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs
new file mode 100644
index 00000000000..33dee31be37
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ claimCoverDrawerFocus,
+ hasCoverDrawerFocusClaim,
+ releaseCoverDrawerFocus,
+} from "./coverDrawerFocusSlot.ts";
+
+test("a fresh claim holds the slot", () => {
+ const claim = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), true);
+});
+
+test("a successor's claim supersedes the outgoing drawer's", () => {
+ // The replacement case: the outgoing drawer's restore is deferred a frame,
+ // and by the time it runs the incoming drawer has claimed and taken focus.
+ const outgoing = claimCoverDrawerFocus();
+ const incoming = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(outgoing), false);
+ assert.equal(hasCoverDrawerFocusClaim(incoming), true);
+});
+
+test("only the newest claim holds the slot across a chain of replacements", () => {
+ const claims = [
+ claimCoverDrawerFocus(),
+ claimCoverDrawerFocus(),
+ claimCoverDrawerFocus(),
+ ];
+
+ const newest = claims.at(-1);
+ for (const claim of claims.slice(0, -1)) {
+ assert.equal(hasCoverDrawerFocusClaim(claim), false);
+ }
+ assert.equal(hasCoverDrawerFocusClaim(newest), true);
+});
+
+test("releasing invalidates the outstanding claim without granting a new one", () => {
+ // The view-mode switch case: nothing replaces the drawer, but the caller has
+ // already placed focus, so the drawer's own restore must not fire.
+ const claim = claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), false);
+});
+
+test("a claim taken after a release holds the slot again", () => {
+ releaseCoverDrawerFocus();
+ const claim = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), true);
+});
+
+test("claims are never reused, so a stale claim cannot alias a live one", () => {
+ const first = claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+ claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+ const later = claimCoverDrawerFocus();
+
+ assert.notEqual(first, later);
+ assert.equal(hasCoverDrawerFocusClaim(first), false);
+});
diff --git a/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts b/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts
new file mode 100644
index 00000000000..21a7c802af9
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts
@@ -0,0 +1,52 @@
+/**
+ * Single-slot coordinator for cover drawer focus restoration.
+ *
+ * There is one covered slot in a channel, so there is one focus claim. A drawer
+ * takes a claim when it captures focus and checks it back at teardown: it hands
+ * focus to whatever it stole it from only if its claim is still the current one.
+ *
+ * This exists because restoration is deferred a frame (the drawer has to let the
+ * exit animation start before moving focus), and a lot can happen in that frame.
+ * When one drawer replaces another the successor mounts and focuses itself while
+ * the outgoing one is still animating out, so an unconditional restore would
+ * yank focus out of the new drawer and into the channel that is now inert —
+ * unreachable by keyboard, with no visible focus ring anywhere.
+ *
+ * A monotonic generation answers "was I superseded?" without anyone having to
+ * name their successor or reason about mount ordering: any newer claim, from any
+ * source, invalidates every older one. That keeps the decision out of the drawer
+ * primitive, which cannot see the surrounding presentation and should not be
+ * interpreting it.
+ */
+
+let generation = 0;
+
+/**
+ * Take the focus slot for a drawer that has just captured focus.
+ *
+ * The returned claim is opaque; pass it to {@link hasCoverDrawerFocusClaim} at
+ * teardown to find out whether this drawer is still the one that owes focus back.
+ */
+export function claimCoverDrawerFocus(): number {
+ generation += 1;
+ return generation;
+}
+
+/** Whether `claim` is still the current claim, i.e. nothing has superseded it. */
+export function hasCoverDrawerFocusClaim(claim: number): boolean {
+ return claim === generation;
+}
+
+/**
+ * Invalidate the outstanding claim because focus has been placed deliberately
+ * elsewhere.
+ *
+ * For transitions that retire a drawer without another drawer replacing it, and
+ * that have already decided where focus belongs — switching a thread from the
+ * focus drawer to the split pane, which moves focus to the view-mode toggle or
+ * the thread body itself. Without this the drawer's own restore would fire a
+ * frame later and pull focus back to whatever opened the thread.
+ */
+export function releaseCoverDrawerFocus(): void {
+ generation += 1;
+}
diff --git a/desktop/src/features/channels/lib/coverDrawerLayout.ts b/desktop/src/features/channels/lib/coverDrawerLayout.ts
new file mode 100644
index 00000000000..00aa710f0f8
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerLayout.ts
@@ -0,0 +1,34 @@
+/**
+ * Layout constants shared by the channel's cover drawers.
+ *
+ * A cover drawer overlays the channel content area with a right-anchored
+ * surface rather than splitting the row into two resizable panes. Both the
+ * focus-mode thread drawer and the agent activity drawer are the same
+ * geometry — only their contents and their open condition differ.
+ */
+
+/**
+ * Width of the channel sliver left visible to the left of a cover drawer.
+ *
+ * Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
+ * full-height click target back to the channel, but narrow enough that the
+ * drawer still reads as the primary surface. The sliver keeps showing the real,
+ * still-mounted channel timeline (dimmed by the scrim) so the user never loses
+ * their place.
+ */
+export const COVER_DRAWER_SLIVER_WIDTH_PX = 72;
+
+/**
+ * Horizontal distance a cover drawer travels on enter/exit.
+ *
+ * Deliberately a fraction of the drawer's own width rather than a true slide
+ * from off-screen: opening a thread is a high-frequency act — threads are chat
+ * sessions and get flipped between constantly — and full-width travel turns a
+ * routine move into ceremony. Short travel keeps it light and repeatable.
+ *
+ * The floor matters as much as the ceiling: the shared 24px side-panel nudge is
+ * only ~3% of this drawer's width, which reads as no movement at all, leaving
+ * the opacity fade as the only perceptible change. This is large enough for the
+ * eye to track a direction and for the ease to have somewhere to decelerate.
+ */
+export const COVER_DRAWER_TRAVEL_PX = 120;
diff --git a/desktop/src/features/channels/lib/threadFocusLayout.ts b/desktop/src/features/channels/lib/threadFocusLayout.ts
index f14f3399bd7..edfbb3a0cde 100644
--- a/desktop/src/features/channels/lib/threadFocusLayout.ts
+++ b/desktop/src/features/channels/lib/threadFocusLayout.ts
@@ -5,17 +5,6 @@
* rather than splitting the row into two resizable panes.
*/
-/**
- * Width of the channel sliver left visible to the left of the focus drawer.
- *
- * Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
- * full-height click target back to the channel, but narrow enough that the
- * drawer still reads as the primary surface. The sliver keeps showing the real,
- * still-mounted channel timeline (dimmed by the scrim) so the user never loses
- * their place.
- */
-export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
-
/**
* Max width of the centered message column inside the focus drawer.
*
@@ -26,21 +15,6 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
*/
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
-/**
- * Horizontal distance the focus drawer travels on enter/exit.
- *
- * Deliberately a fraction of the drawer's own width rather than a true slide
- * from off-screen: opening a thread is a high-frequency act — threads are chat
- * sessions and get flipped between constantly — and full-width travel turns a
- * routine move into ceremony. Short travel keeps it light and repeatable.
- *
- * The floor matters as much as the ceiling: the shared 24px side-panel nudge is
- * only ~3% of this drawer's width, which reads as no movement at all, leaving
- * the opacity fade as the only perceptible change. This is large enough for the
- * eye to track a direction and for the ease to have somewhere to decelerate.
- */
-export const THREAD_FOCUS_DRAWER_TRAVEL_PX = 120;
-
/**
* `AnimatePresence` key shared by both thread layouts.
*
diff --git a/desktop/src/features/channels/ui/AgentActivityDrawer.tsx b/desktop/src/features/channels/ui/AgentActivityDrawer.tsx
new file mode 100644
index 00000000000..e0121baa446
--- /dev/null
+++ b/desktop/src/features/channels/ui/AgentActivityDrawer.tsx
@@ -0,0 +1,43 @@
+import type * as React from "react";
+
+import { CoverDrawer } from "@/features/channels/ui/CoverDrawer";
+
+type AgentActivityDrawerProps = {
+ channelName: string;
+ children: React.ReactNode;
+ onClose: () => void;
+};
+
+/**
+ * The agent activity presentation at wide viewports: a {@link CoverDrawer}
+ * holding the channel-scoped agent session panel.
+ *
+ * Unlike the thread, activity has no split/focus choice — a transcript of tool
+ * calls, diffs, and command output is only legible at this width, so it always
+ * covers and never offers a presentation toggle. That also means it needs no
+ * conditional focus-restore rule: closing it is always a real dismissal, so
+ * focus returns to whatever opened it.
+ *
+ * Escape stays with the panel rather than being claimed by the drawer. The
+ * panel already closes on Escape in this presentation, and routing the key
+ * through its `useEscapeKey` keeps the settings menu's own dismissal first —
+ * the thread drawer claims the key instead because its composer's mention
+ * autocomplete would otherwise swallow a press meant for the thread.
+ */
+export function AgentActivityDrawer({
+ channelName,
+ children,
+ onClose,
+}: AgentActivityDrawerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
index c1933f14bb7..ce4ca8d2049 100644
--- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
+++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
@@ -67,6 +67,12 @@ type AgentSessionThreadPanelProps = {
channel: Channel | null;
channelId?: string | null;
canInterruptTurn: boolean;
+ /**
+ * When false, the panel skips its own slide-in. Set by the cover drawer,
+ * which already animates itself, so the two don't compound into a double
+ * slide. Defaults to animating.
+ */
+ enterMotion?: boolean;
layout?: "standalone" | "split";
isSinglePanelView?: boolean;
profiles?: UserProfileLookup;
@@ -88,6 +94,7 @@ export function AgentSessionThreadPanel({
canInterruptTurn,
channel,
channelId = null,
+ enterMotion = true,
layout = "standalone",
isSinglePanelView = false,
profiles,
@@ -458,6 +465,7 @@ export function AgentSessionThreadPanel({
return (
void;
+ onClose: () => void;
+ openAgentSessionChannelId: string | null;
+ profiles?: UserProfileLookup;
+ useSplitAuxiliaryPane: boolean;
+ widthPx: number;
+ /**
+ * Applies the split-pane presentation, including its resize affordances.
+ * Supplied by `ChannelPane` because that pane owns the resize state; the
+ * cover-drawer presentation is applied here.
+ */
+ wrapSplitPane: (panel: React.ReactNode) => React.ReactNode;
+};
+
+/**
+ * The channel's agent activity surface: the session panel plus the channel
+ * re-scoping its content and actions depend on.
+ *
+ * Split out of `ChannelPane` so the re-scoping rule below has one home and is
+ * not another branch inside that component's auxiliary-surface chain. Which
+ * presentation this lands in is decided upstream and applied through `wrap`.
+ */
+export function ChannelAgentSessionSurface({
+ activeChannel,
+ activeChannelId,
+ activityAgents,
+ agent,
+ isCoverDrawer,
+ isSinglePanelView,
+ onBack,
+ onClose,
+ openAgentSessionChannelId,
+ profiles,
+ useSplitAuxiliaryPane,
+ widthPx,
+ wrapSplitPane,
+}: ChannelAgentSessionSurfaceProps) {
+ // When the panel was opened from a different channel than the currently
+ // active one, re-scope it to the active channel so that both the
+ // content/header AND channel-backed actions (e.g. Stop current turn) operate
+ // on the same channel object.
+ const effectiveAgentSessionChannelId =
+ openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId
+ ? activeChannelId
+ : openAgentSessionChannelId;
+ const channel = effectiveAgentSessionChannelId
+ ? effectiveAgentSessionChannelId === activeChannel.id
+ ? activeChannel
+ : null
+ : agentSessionSelection.isAgentInActivityList({
+ activityAgents,
+ selectedAgent: agent,
+ })
+ ? activeChannel
+ : null;
+
+ const layoutProps = getAgentSessionPanelPresentation({
+ isCoverDrawer,
+ isSinglePanelView,
+ useSplitAuxiliaryPane,
+ });
+ const panel = (
+
+ );
+
+ return isCoverDrawer ? (
+
+ {panel}
+
+ ) : (
+ wrapSplitPane(panel)
+ );
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 4086a4e1c1a..5b7646d2b1d 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -28,16 +28,21 @@ import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib
import { isThreadReply } from "@/features/messages/lib/threading";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
-import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
+import { ChannelAgentSessionSurface } from "@/features/channels/ui/ChannelAgentSessionSurface";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
+import { AGENT_SESSION_SURFACE_KEY } from "@/features/channels/lib/agentSessionPanelPresentation";
+import {
+ resolveChannelAuxiliarySurface,
+ resolveChannelCoverDrawer,
+} from "@/features/channels/lib/channelAuxiliarySurface";
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout";
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
-import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
+import { useCoverDrawerPresence } from "@/features/channels/ui/useCoverDrawerPresence";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
@@ -440,13 +445,37 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
- const useFocusThreadDrawer =
- threadViewMode === "focus" &&
- useSplitAuxiliaryPane &&
- (Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
- const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
- useFocusThreadDrawer,
- onCloseThread,
+ const selectedAgent = React.useMemo(
+ () =>
+ agentSessionSelection.resolveSelectedAgentSession({
+ agentSessionAgents,
+ openAgentSessionPubkey,
+ profilePanelPubkey,
+ profiles,
+ }),
+ [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
+ );
+ // One resolution for both "which panel" and "which presentation", so the two
+ // cover drawers can never stack: there is a single covered slot and the
+ // resolved surface owns it.
+ const auxiliarySurface = resolveChannelAuxiliarySurface({
+ channelManagementOpen,
+ hasActiveChannel: Boolean(activeChannel),
+ hasProfilePanel: Boolean(profilePanelPubkey),
+ hasSelectedAgent: Boolean(selectedAgent),
+ hasThreadHead: Boolean(threadHeadMessage),
+ shouldShowThreadSkeleton,
+ });
+ const coverDrawer = resolveChannelCoverDrawer({
+ surface: auxiliarySurface,
+ threadViewMode,
+ useSplitAuxiliaryPane,
+ });
+ const useFocusThreadDrawer = coverDrawer === "thread";
+ const useAgentActivityDrawer = coverDrawer === "agent-session";
+ const { channelIsCovered, markExitComplete } = useCoverDrawerPresence(
+ coverDrawer !== null,
+ useAgentActivityDrawer ? onCloseAgentSession : onCloseThread,
);
const pendingMainEditRef = React.useRef(null);
const editTargetRef = React.useRef(editTarget);
@@ -530,23 +559,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onExternalTargetResolved: onThreadScrollTargetResolved,
onModeChange: markExitComplete,
});
- const selectedAgent = React.useMemo(
- () =>
- agentSessionSelection.resolveSelectedAgentSession({
- agentSessionAgents,
- openAgentSessionPubkey,
- profilePanelPubkey,
- profiles,
- }),
- [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
- );
const hasSplitAuxiliaryPane =
- useSplitAuxiliaryPane &&
- (channelManagementOpen ||
- Boolean(threadHeadMessage) ||
- shouldShowThreadSkeleton ||
- Boolean(activeChannel && selectedAgent) ||
- Boolean(profilePanelPubkey));
+ useSplitAuxiliaryPane && auxiliarySurface !== null;
const wrapAux = (
panel: React.ReactNode,
testId: string,
@@ -579,6 +593,10 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
+ const wrapAgentSessionSplitPane = (panel: React.ReactNode) =>
+ wrapAux(panel, "agent-session-thread-panel", {
+ key: AGENT_SESSION_SURFACE_KEY,
+ });
const threadHeaderLeading = useSplitAuxiliaryPane ? (
) : undefined;
@@ -826,15 +844,15 @@ export const ChannelPane = React.memo(function ChannelPane({
) : null}
{/*
- * `AnimatePresence` keeps the focus thread drawer mounted through its exit
- * animation — without it the drawer's own existence condition
- * (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes
- * false on the same frame as the close, and there is nothing left to
- * animate. It can hold the real thread through the exit rather than a
- * frozen snapshot because the panel is fully prop-driven.
+ * `AnimatePresence` keeps a cover drawer mounted through its exit
+ * animation — without it the drawer's own existence condition (derived
+ * from `threadHeadMessage` / the selected agent) goes false on the same
+ * frame as the close, and there is nothing left to animate. It can hold
+ * the real content through the exit rather than a frozen snapshot because
+ * both panels are fully prop-driven.
*/}
- {channelManagementOpen && activeChannel ? (
+ {auxiliarySurface === "channel-management" && activeChannel ? (
- ) : threadHeadMessage ? (
+ ) : auxiliarySurface === "thread" && threadHeadMessage ? (
(() => {
const panel = (
{
if (isHuddleTranscript) {
return wrapThreadPanel();
@@ -938,48 +956,26 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapThreadPanel(panel);
})()
- ) : activeChannel && selectedAgent ? (
- (() => {
- // When the panel was opened from a different channel than the
- // currently active one, re-scope it to the active channel so
- // that both the content/header AND channel-backed actions (e.g.
- // Stop current turn) operate on the same channel object.
- const effectiveAgentSessionChannelId =
- openAgentSessionChannelId &&
- activeChannel.id !== openAgentSessionChannelId
- ? activeChannelId
- : openAgentSessionChannelId;
- const panel = (
-
- );
- return wrapAux(panel, "agent-session-thread-panel");
- })()
- ) : profilePanelPubkey ? (
+ ) : auxiliarySurface === "agent-session" &&
+ activeChannel &&
+ selectedAgent ? (
+
+ ) : auxiliarySurface === "profile" && profilePanelPubkey ? (
(() => {
const panel = (
void;
+ /**
+ * Whether the drawer claims Escape for itself, ahead of anything inside it.
+ *
+ * Claiming it means a single press always leaves, even from a nested control
+ * that would otherwise handle the key. Leave this off when the drawer's own
+ * content already closes on Escape through `useEscapeKey`, which yields to
+ * nested controls that mark the event handled. Defaults to claiming.
+ */
+ ownsEscape?: boolean;
+ /**
+ * Whether content inside the drawer currently owns Escape ahead of the
+ * drawer's own claim.
+ *
+ * Only meaningful while `ownsEscape` is set. A capture-phase claim runs before
+ * anything inside the drawer, so a drawer that unconditionally closes on
+ * Escape would dismiss the whole surface out from under an in-progress edit
+ * instead of letting that edit cancel first — losing the draft. Setting this
+ * yields the press to the drawer's own subtree for exactly that case; presses
+ * from outside the drawer still close it, so it cannot be wedged open.
+ */
+ escapeYieldsToContent?: boolean;
+ /** Accessible name for the scrim, which is the click target back to the channel. */
+ scrimLabel: string;
+ /**
+ * Test id of the drawer surface. The overlay and scrim derive theirs from it
+ * (`-overlay`, `-scrim`) so one id names the whole presentation.
+ */
+ testId: string;
+};
+
+/**
+ * Scrim over the channel content area behind a cover drawer.
+ *
+ * Veil, not shadow, and no blur: the channel fades toward the surface colour
+ * rather than being darkened. A black wash is a multiply — it scales text and
+ * background down together, so dark-on-light text keeps its contrast ratio and
+ * stays readable at any opacity short of a solid bar. Fading toward
+ * `background` instead compresses text against the surface in both themes,
+ * which is what pushes the sliver back to colour and shape. Matches the shared
+ * header backdrop's `bg-background/80` vocabulary, a touch heavier because this
+ * one has to defeat body text rather than sit over a gap.
+ */
+const COVER_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
+
+/**
+ * Hover eases the veil one step in both themes.
+ *
+ * Feedback that the sliver is a target — deliberately not a peek: one step is
+ * enough to register as interactive without making the channel readable.
+ */
+const COVER_SCRIM_HOVER_CLASS =
+ "hover:bg-background/65 dark:hover:bg-background/70";
+
+/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
+const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
+
+/**
+ * Leave immediately. Shares the enter's fast-start shape rather than the
+ * conventional accelerating ease-in for exits.
+ *
+ * The "exits accelerate away" rule assumes the whole travel is visible; an
+ * ease-in spends its opening frames barely moving and pays that back at the end.
+ * Here the tail is hidden under the opacity fade, so acceleration buys nothing
+ * and those opening frames are the entire perception of responsiveness — a
+ * dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
+ * its total duration. Decisiveness comes from the duration below instead.
+ */
+const EXIT_EASE = ENTER_EASE;
+
+const SCRIM_ENTER_SECONDS = 0.2;
+
+/**
+ * Slightly ahead of the drawer's exit, and deliberately so.
+ *
+ * A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
+ * of it, which reads as lag at the exact moment the user has committed to
+ * leaving. Undimming first hands the channel back the instant it is asked for.
+ */
+const SCRIM_EXIT_SECONDS = 0.12;
+
+/**
+ * Enter: opacity front-loaded, transform long.
+ *
+ * The two channels animate over deliberately different windows, and that
+ * asymmetry is the whole point. Short travel *requires* an opacity fade — an
+ * opaque surface this large appearing 120px off its mark with no fade is a hard
+ * cut, not a slide. But pairing both properties on one timing function (as a
+ * single CSS keyframe must) welds them together for the full duration, and since
+ * opacity covers 100% of its range while transform covers ~3% of the drawer's
+ * width, the fade is what the eye reads. Resolving opacity in the first ~90ms
+ * leaves the remaining ~190ms as pure travel: the fade is over before it
+ * registers, and what's perceived is sliding.
+ *
+ * It also keeps the drawer's own entrance from exposing its contents' load
+ * order. Anything arriving late (replies resolving, media decoding) lands on an
+ * already-opaque surface and reads as "the panel is loading" rather than the UI
+ * assembling itself.
+ */
+const ENTER_TRANSITION = {
+ opacity: { duration: 0.09, ease: "linear" },
+ x: { duration: 0.28, ease: ENTER_EASE },
+} as const;
+
+/**
+ * Exit: half the enter's duration, opacity barely back-loaded.
+ *
+ * Opening and closing are not symmetric tasks. The enter has something to say —
+ * it establishes where the panel came from and that the channel is still behind
+ * it. The exit has nothing to say: attention has already left for the channel,
+ * so its only job is to get out of the way without popping. That makes duration
+ * the thing to spend, and 140ms is about the floor before the drawer reads as
+ * vanishing rather than leaving.
+ *
+ * The opacity hold shrinks with it. Its purpose is to let the drawer commit to
+ * moving before it dissolves, so it reads as sliding out — but at this duration a
+ * hold proportional to the old one would eat half the animation. 20ms is enough
+ * to register solidity in the first frame or two.
+ */
+const EXIT_TRANSITION = {
+ opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
+ x: { duration: 0.14, ease: EXIT_EASE },
+} as const;
+
+/**
+ * Reduced motion keeps a crossfade and drops the travel.
+ *
+ * Travel is the part that's motion; the fade is what makes appearing and
+ * disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
+ * timings would read as dead air on a stationary surface, so both collapse to
+ * one short symmetric fade.
+ */
+const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
+
+/**
+ * Right-anchored drawer that overlays the channel content area.
+ *
+ * Presentation only — it knows nothing about what it covers the channel with.
+ * The thread focus drawer and the agent activity drawer are both this surface
+ * with different contents and different open conditions.
+ *
+ * Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
+ * `AnimatePresence` so the exit animation can run: everything here is absolutely
+ * positioned against the channel content area, so the app sidebar is never
+ * covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
+ * of it remains visible for depth, and the whole scrim (sliver included) is one
+ * tall click target back to the channel. Orientation lives in the drawer's own
+ * header, where the eye already is — the sliver carries no label of its own.
+ *
+ * `z-41` places the drawer above the channel section (whose inner `isolate`
+ * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop
+ * overlay) and the `z-30` shared header backdrop, while staying below the
+ * global `z-45` top chrome. Setting z-index on the positioned container also
+ * gives the drawer its own stacking context, so the panel chrome inside is
+ * isolated.
+ */
+export function CoverDrawer({
+ ariaLabel,
+ children,
+ escapeYieldsToContent = false,
+ onClose,
+ ownsEscape = true,
+ scrimLabel,
+ testId,
+}: CoverDrawerProps) {
+ const prefersReducedMotion = useReducedMotion();
+ /**
+ * False from the moment `AnimatePresence` starts this drawer's exit.
+ *
+ * The covered slot belongs to the drawer that is arriving or settled, not to
+ * one that is animating away, and this is the only signal that distinguishes
+ * them — the focus slot cannot, because a drawer that never captures focus
+ * (its content may take it instead) leaves the outgoing drawer's claim
+ * current. See the Escape handler.
+ */
+ const isPresent = useIsPresent();
+ const travelPx = prefersReducedMotion ? 0 : COVER_DRAWER_TRAVEL_PX;
+ const drawerRef = React.useRef(null);
+ const previousFocusRef = React.useRef(null);
+ /**
+ * Whether the opener has been captured for this drawer instance.
+ *
+ * Distinct from `previousFocusRef.current === null`, which is a legitimate
+ * capture (nothing was focused) and must not be retried. See the capture
+ * effect for why one attempt is all this gets.
+ */
+ const hasCapturedPreviousFocusRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!ownsEscape) return;
+ // Stand down for the whole exit: a drawer on its way out does not own the
+ // covered slot, so the key belongs to whatever replaced it.
+ //
+ // `AnimatePresence` keeps a replaced drawer mounted through its exit
+ // animation, so during a replacement two drawers have this listener
+ // installed at once, and capture-phase listeners on the same target fire in
+ // registration order — the outgoing one registered first, so it would
+ // otherwise always win. It then consumes the press via
+ // `stopImmediatePropagation`, which is invisible to the successor, and the
+ // user has to press Escape twice to leave the drawer that just arrived.
+ //
+ // `useEscapeKey` carries the same guard for the same reason. This one is not
+ // sufficient on its own: the agent activity drawer sets `ownsEscape={false}`
+ // and routes the key through its panel, so on that path no code here runs
+ // and it is the exiting *panel*'s `preventDefault` that swallows the press.
+ //
+ // Gating on presence rather than the focus slot is deliberate. The focus
+ // slot is claimed only by a drawer that captures focus, and a successor
+ // whose content takes focus instead never claims it — which leaves the
+ // outgoing drawer's claim current and makes a slot check pass for exactly
+ // the drawer that should stand down. Presence is the state that actually
+ // distinguishes arriving from leaving.
+ if (!isPresent) return;
+
+ function handleEscape(event: KeyboardEvent) {
+ if (event.key !== "Escape") return;
+ // Yield to an in-progress edit inside the drawer: the capture-phase claim
+ // runs first, so without this the press would dismiss the whole surface
+ // and lose the draft instead of cancelling the edit. Scoped to the
+ // drawer's own subtree, so a press from outside still closes it.
+ const target = event.target;
+ if (
+ escapeYieldsToContent &&
+ target instanceof Node &&
+ drawerRef.current?.contains(target)
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ onClose();
+ }
+
+ window.addEventListener("keydown", handleEscape, { capture: true });
+ return () => {
+ window.removeEventListener("keydown", handleEscape, { capture: true });
+ };
+ }, [escapeYieldsToContent, isPresent, onClose, ownsEscape]);
+
+ React.useLayoutEffect(() => {
+ // Capture the opener exactly once per drawer instance.
+ //
+ // `React.StrictMode` replays effects in development as setup → cleanup →
+ // setup, and by that second setup this drawer has already focused itself. An
+ // unconditional read of `document.activeElement` would therefore record the
+ // drawer as its own opener, and a real close would focus a node React has
+ // since detached — leaving focus on ``, keyboard-stranded. Refs survive
+ // the replay, so a one-shot flag is enough. The flag is deliberately not
+ // reset in cleanup: the only cleanup it would see before a real close is the
+ // simulated one, which is exactly what it exists to ignore.
+ //
+ // Re-claiming the focus slot on the replayed setup is correct and stays as
+ // is — that new generation is what makes the first cleanup's deferred
+ // restore stand down.
+ if (!hasCapturedPreviousFocusRef.current) {
+ hasCapturedPreviousFocusRef.current = true;
+ previousFocusRef.current =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ }
+ const focusClaim = claimCoverDrawerFocus();
+ drawerRef.current?.focus({ preventScroll: true });
+
+ return () => {
+ const previousFocus = previousFocusRef.current;
+ requestAnimationFrame(() => {
+ // Deferred by a frame so the exit animation can start, which is exactly
+ // long enough for a replacing drawer to mount and take focus. Restore
+ // only while this drawer still holds the slot; otherwise the successor
+ // owns focus and restoring would drop it into the inert channel.
+ if (!hasCoverDrawerFocusClaim(focusClaim)) return;
+ previousFocus?.focus({ preventScroll: true });
+ });
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs
new file mode 100644
index 00000000000..e164317ca99
--- /dev/null
+++ b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs
@@ -0,0 +1,381 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("", {
+ pretendToBeVisual: true,
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ Element: dom.window.Element,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ KeyboardEvent: dom.window.KeyboardEvent,
+ MutationObserver: dom.window.MutationObserver,
+ Node: dom.window.Node,
+ cancelAnimationFrame: dom.window.cancelAnimationFrame,
+ document: dom.window.document,
+ requestAnimationFrame: dom.window.requestAnimationFrame,
+ window: dom.window,
+ });
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: dom.window.navigator,
+ writable: true,
+ });
+ dom.window.matchMedia ??= () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+/**
+ * Renders a drawer holding one focusable child, which stands in for the thread
+ * composer that owns Escape while an edit is in progress.
+ */
+async function renderDrawer({ escapeYieldsToContent }) {
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const closes = [];
+ const view = render(
+ React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ escapeYieldsToContent,
+ onClose: () => closes.push("close"),
+ scrimLabel: "Back to #general",
+ testId: "cover-drawer",
+ },
+ React.createElement("input", { "data-testid": "thread-composer" }),
+ ),
+ );
+
+ return { closes, view };
+}
+
+/**
+ * Renders the replacement window the way `ChannelPane` produces it: one
+ * `AnimatePresence` whose keyed child is swapped, so the outgoing drawer stays
+ * mounted in its exit phase while the successor mounts alongside it.
+ *
+ * Both are the real primitive, and the presence wrapper is real too, because the
+ * bug lives in the interaction between two instances under `AnimatePresence` —
+ * a harness that renders them as two independent trees reports both as present
+ * and cannot see it.
+ */
+async function renderReplacement({ successorOwnsEscape }) {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { AnimatePresence } = await import("motion/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const events = [];
+
+ // Stands in for the agent session panel's own `useEscapeKey`: the successor
+ // drawer does not claim the key, its content handles it.
+ function SuccessorContent() {
+ React.useEffect(() => {
+ function onKeyDown(event) {
+ if (event.key === "Escape") events.push("successor-content-escape");
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+ return React.createElement("div", { "data-testid": "successor-content" });
+ }
+
+ const outgoing = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ key: "outgoing",
+ onClose: () => events.push("outgoing-close"),
+ scrimLabel: "Back to #general",
+ testId: "outgoing-drawer",
+ },
+ React.createElement("div", null, "thread"),
+ );
+ const successor = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Agent activity",
+ key: "successor",
+ onClose: () => events.push("successor-close"),
+ ownsEscape: successorOwnsEscape,
+ scrimLabel: "Back to #general",
+ testId: "successor-drawer",
+ },
+ React.createElement(SuccessorContent),
+ );
+
+ const view = render(React.createElement(AnimatePresence, null, outgoing));
+ await act(async () => {
+ view.rerender(React.createElement(AnimatePresence, null, successor));
+ });
+
+ // Both are mounted: the outgoing drawer is held through its exit animation.
+ // This is the ~210ms window an rAF probe measures in the browser, and it is
+ // the precondition for the assertions below — without it they prove nothing.
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="outgoing-drawer"]')
+ .length,
+ 1,
+ );
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="successor-drawer"]')
+ .length,
+ 1,
+ );
+
+ return { events, view };
+}
+
+function pressEscapeOn(element) {
+ element.dispatchEvent(
+ new dom.window.KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Escape",
+ }),
+ );
+}
+
+/**
+ * Renders the replacement window in the shape production actually has it: each
+ * drawer holds a panel using the real `useEscapeKey`, which is how both the
+ * thread and the agent session panels take the key.
+ *
+ * The synthetic harness above cannot see the second half of this bug. Its
+ * successor listener acts on every press, but `useEscapeKey` deliberately
+ * ignores an event that is already `defaultPrevented` — so an exiting *panel*
+ * that still calls `preventDefault` swallows the press from a real successor
+ * just as thoroughly as the exiting drawer's `stopImmediatePropagation` does,
+ * one layer further down and with no cover-drawer code in the path.
+ */
+async function renderPanelReplacement() {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { AnimatePresence } = await import("motion/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+ const { useEscapeKey } = await import("@/shared/hooks/useEscapeKey.ts");
+
+ const events = [];
+
+ function Panel({ label, testId }) {
+ useEscapeKey(
+ React.useCallback(() => events.push(label), [label]),
+ true,
+ );
+ return React.createElement("div", { "data-testid": testId });
+ }
+
+ // The thread drawer claims Escape itself; activity leaves it to its panel.
+ const outgoing = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ key: "outgoing",
+ onClose: () => events.push("outgoing-drawer-close"),
+ scrimLabel: "Back to #general",
+ testId: "outgoing-drawer",
+ },
+ React.createElement(Panel, {
+ label: "outgoing-panel-escape",
+ testId: "outgoing-panel",
+ }),
+ );
+ const successor = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Agent activity",
+ key: "successor",
+ onClose: () => events.push("successor-drawer-close"),
+ ownsEscape: false,
+ scrimLabel: "Back to #general",
+ testId: "successor-drawer",
+ },
+ React.createElement(Panel, {
+ label: "successor-panel-escape",
+ testId: "successor-panel",
+ }),
+ );
+
+ const view = render(React.createElement(AnimatePresence, null, outgoing));
+ await act(async () => {
+ view.rerender(React.createElement(AnimatePresence, null, successor));
+ });
+
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="outgoing-drawer"]')
+ .length,
+ 1,
+ );
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="successor-panel"]')
+ .length,
+ 1,
+ );
+
+ return { events, view };
+}
+
+function composer() {
+ return dom.window.document.querySelector('[data-testid="thread-composer"]');
+}
+
+test("Escape inside the drawer yields to content while it owns the key", async () => {
+ // The regression this guards (#6575): the drawer claims Escape in the capture
+ // phase, which runs before the composer's own handler. Without the yield, one
+ // press dismisses the entire drawer instead of cancelling the in-progress
+ // edit, and the unsaved draft goes with it.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: true });
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, []);
+});
+
+test("Escape inside the drawer closes it when content does not own the key", async () => {
+ // The default: with no active edit the same press is a dismissal, so the yield
+ // above must be conditional rather than a blanket exemption for the subtree.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: false });
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("Escape from outside the drawer closes it even while content owns the key", async () => {
+ // The yield is scoped to the drawer's own subtree, so an active edit inside
+ // cannot wedge the drawer open against a press from the channel behind it.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: true });
+
+ pressEscapeOn(dom.window.document.body);
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("Escape during a replacement reaches the successor, not the exiting drawer", async () => {
+ // The bug this guards: `AnimatePresence` holds the outgoing drawer mounted
+ // through its exit animation (~210ms), and its capture-phase listener calls
+ // `stopImmediatePropagation()`. A successor that does not claim Escape — the
+ // agent activity drawer, which routes the key through its panel's own
+ // `useEscapeKey` — therefore never sees the press, so the user has to press
+ // Escape twice to leave a drawer that just replaced another.
+ const { events, view } = await renderReplacement({
+ successorOwnsEscape: false,
+ });
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-content"]'),
+ );
+
+ // The press belongs to the drawer holding the covered slot. The exiting
+ // drawer is on its way out and must not act on it, let alone consume it.
+ assert.deepEqual(events, ["successor-content-escape"]);
+
+ view.unmount();
+});
+
+test("a claiming successor closes on a single Escape during a replacement", async () => {
+ // The same window, with a successor that does claim the key (thread over
+ // activity): exactly one drawer may act, and it must be the new one.
+ const { events, view } = await renderReplacement({
+ successorOwnsEscape: true,
+ });
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-content"]'),
+ );
+
+ assert.deepEqual(events, ["successor-close"]);
+
+ view.unmount();
+});
+
+test("Escape during a panel replacement reaches the successor's panel", async () => {
+ // The other half of the same bug, one layer down and with no cover-drawer
+ // code in the path: `useEscapeKey` ignores an event that is already
+ // `defaultPrevented`, so an exiting panel that still calls `preventDefault`
+ // silently consumes the press from its successor's panel. This is the path the
+ // agent activity drawer actually takes — it sets `ownsEscape={false}` and lets
+ // its panel handle the key — so fixing only the drawer's claim leaves the
+ // two-press bug in place.
+ const { events, view } = await renderPanelReplacement();
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-panel"]'),
+ );
+
+ // Exactly one handler acts, and it belongs to the arriving surface.
+ assert.deepEqual(events, ["successor-panel-escape"]);
+
+ view.unmount();
+});
+
+test("a lone panel still closes on Escape outside AnimatePresence", async () => {
+ // `useEscapeKey` is used by panels that never animate out (split pane,
+ // single-panel thread, profile). With no `AnimatePresence` above them there is
+ // no presence context at all, and the guard must read as present rather than
+ // as "not exiting yet" — otherwise it would disable Escape for every one of
+ // those surfaces.
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { useEscapeKey } = await import("@/shared/hooks/useEscapeKey.ts");
+
+ const closes = [];
+ function Panel() {
+ useEscapeKey(() => closes.push("close"), true);
+ return React.createElement("input", { "data-testid": "thread-composer" });
+ }
+ render(React.createElement(Panel));
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("a lone drawer still closes on Escape under StrictMode", async () => {
+ // The slot claim is taken in a layout effect, which `React.StrictMode` replays
+ // as setup → cleanup → setup. Each setup takes a *new* generation, so the
+ // guard above must be reading whatever the last setup stored rather than a
+ // stale claim from the discarded first pass — otherwise every drawer in
+ // development would ignore Escape entirely. The focus tests cannot see this:
+ // they assert on restore, which the coordinator handles separately.
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const closes = [];
+ render(
+ React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ onClose: () => closes.push("close"),
+ scrimLabel: "Back to #general",
+ testId: "strict-drawer",
+ },
+ React.createElement("input", { "data-testid": "thread-composer" }),
+ ),
+ { reactStrictMode: true },
+ );
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
diff --git a/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs b/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs
new file mode 100644
index 00000000000..ce07ddc7e08
--- /dev/null
+++ b/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs
@@ -0,0 +1,185 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+// `pretendToBeVisual` is what gives jsdom `requestAnimationFrame`. The drawer
+// defers its focus restore to one, so without it the restore silently never
+// runs and every assertion here would pass for the wrong reason.
+const dom = new JSDOM("", {
+ pretendToBeVisual: true,
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ Element: dom.window.Element,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ MutationObserver: dom.window.MutationObserver,
+ Node: dom.window.Node,
+ // The drawer calls the bare global, not `window.requestAnimationFrame`.
+ cancelAnimationFrame: dom.window.cancelAnimationFrame,
+ document: dom.window.document,
+ requestAnimationFrame: dom.window.requestAnimationFrame,
+ window: dom.window,
+ });
+ // `navigator` is getter-only on Node, so it needs defineProperty rather than
+ // assignment; motion/react reads it during render.
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: dom.window.navigator,
+ writable: true,
+ });
+ // `useReducedMotion` subscribes to a media query jsdom does not implement.
+ dom.window.matchMedia ??= () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+/**
+ * The drawer defers its focus restore to a `requestAnimationFrame`, so every
+ * assertion here has to run after that frame has actually fired. Two hops:
+ * React commits the unmount, then the rAF callback runs.
+ */
+async function flushDeferredFocusRestore(act) {
+ for (let hop = 0; hop < 2; hop += 1) {
+ await act(async () => {
+ await new Promise((resolve) =>
+ dom.window.requestAnimationFrame(() => resolve()),
+ );
+ });
+ }
+}
+
+async function loadHarness() {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+ const { releaseCoverDrawerFocus } = await import(
+ "@/features/channels/lib/coverDrawerFocusSlot"
+ );
+
+ const opener = dom.window.document.createElement("button");
+ opener.setAttribute("data-testid", "opener");
+ dom.window.document.body.append(opener);
+ opener.focus();
+ assert.equal(dom.window.document.activeElement, opener);
+
+ const drawer = (testId) =>
+ React.createElement(
+ CoverDrawer,
+ { ariaLabel: testId, onClose: () => {}, scrimLabel: testId, testId },
+ React.createElement("div", null, testId),
+ );
+
+ return { act, drawer, opener, releaseCoverDrawerFocus, render };
+}
+
+function activeTestId() {
+ return (
+ dom.window.document.activeElement?.getAttribute("data-testid") ??
+ dom.window.document.activeElement?.tagName ??
+ "none"
+ );
+}
+
+test("a drawer restores focus to whatever it covered when it simply closes", async () => {
+ const { act, drawer, opener, render } = await loadHarness();
+ const view = render(drawer("first-drawer"));
+ assert.equal(activeTestId(), "first-drawer");
+
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(dom.window.document.activeElement, opener);
+});
+
+test("a replaced drawer leaves focus with its successor, not the covered content", async () => {
+ // The bug this guards: restoration is deferred a frame, and in that frame the
+ // successor has already mounted and taken focus. An unconditional restore
+ // yanks focus back out of the new drawer and into content that is now inert —
+ // keyboard-dead, with no visible focus ring anywhere on screen.
+ const { act, drawer, render } = await loadHarness();
+ const view = render(drawer("outgoing-drawer"));
+
+ // Replacement, with no fully-closed intermediate state: the successor mounts
+ // and claims focus while the outgoing drawer is still animating out.
+ const successor = render(drawer("incoming-drawer"));
+ assert.equal(activeTestId(), "incoming-drawer");
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "incoming-drawer");
+ successor.unmount();
+});
+
+test("only the last-opened drawer keeps focus across a chain of replacements", async () => {
+ const { act, drawer, render } = await loadHarness();
+ const first = render(drawer("first-drawer"));
+ const second = render(drawer("second-drawer"));
+ const third = render(drawer("third-drawer"));
+
+ await act(async () => {
+ first.unmount();
+ second.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "third-drawer");
+ third.unmount();
+});
+
+test("a drawer restores focus to the opener even when StrictMode replays its capture", async () => {
+ // The app root is wrapped in `React.StrictMode`, which in development runs
+ // every effect setup → cleanup → setup. By that second setup the drawer has
+ // already focused itself, so a capture that reads `document.activeElement`
+ // unconditionally records the drawer as its own opener; closing then focuses a
+ // node React has detached and focus falls to ``. Capture has to survive
+ // the replay, which is what the other tests here cannot see because they
+ // render without StrictMode.
+ const { act, drawer, opener, render } = await loadHarness();
+ const view = render(drawer("strict-drawer"), { reactStrictMode: true });
+ assert.equal(activeTestId(), "strict-drawer");
+
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(dom.window.document.activeElement, opener);
+});
+
+test("a released slot leaves focus where the caller put it", async () => {
+ // The thread view-mode switch: nothing replaces the drawer, but the switch has
+ // already decided where focus belongs, so the drawer's own restore must not
+ // fire and drag focus back to whatever opened the thread.
+ const { act, drawer, releaseCoverDrawerFocus, render } = await loadHarness();
+ const view = render(drawer("thread-drawer"));
+
+ const splitPane = dom.window.document.createElement("button");
+ splitPane.setAttribute("data-testid", "split-pane");
+ dom.window.document.body.append(splitPane);
+
+ await act(async () => {
+ releaseCoverDrawerFocus();
+ splitPane.focus();
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "split-pane");
+});
diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
index 410f2d8672d..8fc98bb62e6 100644
--- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
+++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
@@ -1,141 +1,26 @@
-import { motion, useReducedMotion } from "motion/react";
-import * as React from "react";
+import type * as React from "react";
-import {
- THREAD_FOCUS_DRAWER_TRAVEL_PX,
- THREAD_FOCUS_SLIVER_WIDTH_PX,
-} from "@/features/channels/lib/threadFocusLayout";
-import { getThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
-import { cn } from "@/shared/lib/cn";
+import { CoverDrawer } from "@/features/channels/ui/CoverDrawer";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
+ /**
+ * Whether the thread has an edit in progress, which Escape must cancel before
+ * it can dismiss the drawer. See `CoverDrawer`'s `escapeYieldsToContent`.
+ */
hasActiveEdit: boolean;
onClose: () => void;
};
/**
- * Scrim over the channel content area behind the focus drawer.
+ * The focus-mode thread presentation: a {@link CoverDrawer} holding the thread.
*
- * Veil, not shadow, and no blur: the channel fades toward the surface colour
- * rather than being darkened. A black wash is a multiply — it scales text and
- * background down together, so dark-on-light text keeps its contrast ratio and
- * stays readable at any opacity short of a solid bar. Fading toward
- * `background` instead compresses text against the surface in both themes,
- * which is what pushes the sliver back to colour and shape. Matches the shared
- * header backdrop's `bg-background/80` vocabulary, a touch heavier because this
- * one has to defeat body text rather than sit over a gap.
- */
-const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
-
-/**
- * Hover eases the veil one step in both themes.
- *
- * Feedback that the sliver is a target — deliberately not a peek: one step is
- * enough to register as interactive without making the channel readable.
- */
-const FOCUS_SCRIM_HOVER_CLASS =
- "hover:bg-background/65 dark:hover:bg-background/70";
-
-/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
-const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
-
-/**
- * Leave immediately. Shares the enter's fast-start shape rather than the
- * conventional accelerating ease-in for exits.
- *
- * The "exits accelerate away" rule assumes the whole travel is visible; an
- * ease-in spends its opening frames barely moving and pays that back at the end.
- * Here the tail is hidden under the opacity fade, so acceleration buys nothing
- * and those opening frames are the entire perception of responsiveness — a
- * dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
- * its total duration. Decisiveness comes from the duration below instead.
- */
-const EXIT_EASE = ENTER_EASE;
-
-const SCRIM_ENTER_SECONDS = 0.2;
-
-/**
- * Slightly ahead of the drawer's exit, and deliberately so.
- *
- * A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
- * of it, which reads as lag at the exact moment the user has committed to
- * leaving. Undimming first hands the channel back the instant it is asked for.
- */
-const SCRIM_EXIT_SECONDS = 0.12;
-
-/**
- * Enter: opacity front-loaded, transform long.
- *
- * The two channels animate over deliberately different windows, and that
- * asymmetry is the whole point. Short travel *requires* an opacity fade — an
- * opaque surface this large appearing 120px off its mark with no fade is a hard
- * cut, not a slide. But pairing both properties on one timing function (as a
- * single CSS keyframe must) welds them together for the full duration, and since
- * opacity covers 100% of its range while transform covers ~3% of the drawer's
- * width, the fade is what the eye reads. Resolving opacity in the first ~90ms
- * leaves the remaining ~190ms as pure travel: the fade is over before it
- * registers, and what's perceived is sliding.
- *
- * It also keeps the drawer's own entrance from exposing its contents' load
- * order. Anything arriving late (replies resolving, media decoding) lands on an
- * already-opaque surface and reads as "the thread is loading" rather than the UI
- * assembling itself.
- */
-const ENTER_TRANSITION = {
- opacity: { duration: 0.09, ease: "linear" },
- x: { duration: 0.28, ease: ENTER_EASE },
-} as const;
-
-/**
- * Exit: half the enter's duration, opacity barely back-loaded.
- *
- * Opening and closing are not symmetric tasks. The enter has something to say —
- * it establishes where the thread came from and that the channel is still behind
- * it. The exit has nothing to say: attention has already left for the channel,
- * so its only job is to get out of the way without popping. That makes duration
- * the thing to spend, and 140ms is about the floor before the drawer reads as
- * vanishing rather than leaving.
- *
- * The opacity hold shrinks with it. Its purpose is to let the drawer commit to
- * moving before it dissolves, so it reads as sliding out — but at this duration a
- * hold proportional to the old one would eat half the animation. 20ms is enough
- * to register solidity in the first frame or two.
- */
-const EXIT_TRANSITION = {
- opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
- x: { duration: 0.14, ease: EXIT_EASE },
-} as const;
-
-/**
- * Reduced motion keeps a crossfade and drops the travel.
- *
- * Travel is the part that's motion; the fade is what makes appearing and
- * disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
- * timings would read as dead air on a stationary surface, so both collapse to
- * one short symmetric fade.
- */
-const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
-
-/**
- * Right-anchored thread drawer that overlays the channel content area.
- *
- * Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
- * `AnimatePresence` so the exit animation can run: everything here is absolutely
- * positioned against the channel content area, so the app sidebar is never
- * covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
- * of it remains visible for depth, and the whole scrim (sliver included) is one
- * tall click target back to the channel. Orientation lives in the drawer
- * header's breadcrumb, where the eye already is — the sliver carries no label of
- * its own.
- *
- * `z-41` places the drawer above the channel section (whose inner `isolate`
- * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop
- * overlay) and the `z-30` shared header backdrop, while staying below the
- * global `z-45` top chrome. Setting z-index on the positioned container also
- * gives the drawer its own stacking context, so the panel chrome inside is
- * isolated.
+ * Everything about the surface itself — motion, scrim, Escape, focus
+ * capture/restore — lives in `CoverDrawer`. Switching this thread to the split
+ * pane is not a dismissal and must not restore focus to whatever opened the
+ * thread; that case is handled where the switch happens, by releasing the
+ * drawer's focus slot before this unmounts. See `useThreadViewModeSwitch`.
*/
export function FocusThreadDrawer({
channelName,
@@ -143,113 +28,15 @@ export function FocusThreadDrawer({
hasActiveEdit,
onClose,
}: FocusThreadDrawerProps) {
- const prefersReducedMotion = useReducedMotion();
- const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
- const drawerRef = React.useRef(null);
- const previousFocusRef = React.useRef(null);
-
- React.useEffect(() => {
- function handleEscape(event: KeyboardEvent) {
- if (event.key !== "Escape") return;
- const target = event.target;
- if (
- hasActiveEdit &&
- target instanceof Node &&
- drawerRef.current?.contains(target)
- ) {
- return;
- }
- event.preventDefault();
- event.stopImmediatePropagation();
- onClose();
- }
-
- window.addEventListener("keydown", handleEscape, { capture: true });
- return () => {
- window.removeEventListener("keydown", handleEscape, { capture: true });
- };
- }, [hasActiveEdit, onClose]);
-
- React.useLayoutEffect(() => {
- previousFocusRef.current =
- document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null;
- drawerRef.current?.focus({ preventScroll: true });
-
- return () => {
- const previousFocus = previousFocusRef.current;
- requestAnimationFrame(() => {
- // A real dismissal keeps focus mode selected; a presentation switch
- // has already selected split mode and owns focus inside the new panel.
- if (getThreadViewMode() === "focus") {
- previousFocus?.focus({ preventScroll: true });
- }
- });
- };
- }, []);
-
return (
-
+ {children}
+
);
}
diff --git a/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs b/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs
new file mode 100644
index 00000000000..066e7be6054
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs
@@ -0,0 +1,197 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+const AGENT_PUBKEY = "a".repeat(64);
+const THREAD_HEAD_ID = "thread-head-1";
+const OTHER_THREAD_HEAD_ID = "thread-head-2";
+
+/**
+ * Renders the real hook over a recording state harness that re-renders on every
+ * write, the way the panel state hooks do — the open handlers read the current
+ * thread/profile state as props, so a plain mutable object would hand them stale
+ * values and the breadcrumb assertions would silently pass for the wrong reason.
+ *
+ * Last-opened-wins is a property of these two handlers clearing each other's
+ * state, so the assertions are on the state writes, not on a rendered surface.
+ */
+async function renderAgentSessionHandlers({
+ openThreadHeadId = null,
+ requireThreadEditResolution = () => true,
+} = {}) {
+ const React = await import("react");
+ const { act, renderHook } = await import("@testing-library/react");
+ const { useChannelAgentSessions } = await import(
+ "./useChannelAgentSessions.ts"
+ );
+
+ const state = {
+ channelManagementOpen: false,
+ openAgentSessionChannelId: null,
+ openAgentSessionPubkey: null,
+ openThreadHeadId,
+ profilePanelPubkey: null,
+ };
+ const openedThreads = [];
+ let commit = () => {};
+ const write = (key, value) => {
+ state[key] = value;
+ commit();
+ };
+
+ const rendered = renderHook(() => {
+ const [, force] = React.useState(0);
+ commit = () => force((version) => version + 1);
+
+ return useChannelAgentSessions({
+ activeChannel: { id: "channel-1", name: "general" },
+ activeChannelId: "channel-1",
+ agentsLoaded: true,
+ channelMembers: [{ pubkey: AGENT_PUBKEY, role: "bot" }],
+ handleOpenThread: (message) => {
+ openedThreads.push(message.id);
+ write("openThreadHeadId", message.id);
+ },
+ managedAgents: [
+ {
+ agentSource: "managed",
+ canInterruptTurn: true,
+ name: "ss-dev-00",
+ pubkey: AGENT_PUBKEY,
+ status: "deployed",
+ },
+ ],
+ openAgentSessionPubkey: state.openAgentSessionPubkey,
+ openThreadHeadId: state.openThreadHeadId,
+ profilePanelPubkey: state.profilePanelPubkey,
+ requireThreadEditResolution,
+ setChannelManagementOpen: (open) => write("channelManagementOpen", open),
+ setExpandedThreadReplyIds: () => {},
+ setOpenAgentSessionChannelId: (value) =>
+ write("openAgentSessionChannelId", value),
+ setOpenAgentSessionPubkey: (value) =>
+ write("openAgentSessionPubkey", value),
+ setOpenThreadHeadId: (value) => write("openThreadHeadId", value),
+ setProfilePanelPubkey: (value) => write("profilePanelPubkey", value),
+ setThreadReplyTargetId: () => {},
+ setThreadScrollTargetId: () => {},
+ });
+ });
+
+ const run = (body) => act(async () => body(rendered.result.current));
+
+ return { openedThreads, run, state };
+}
+
+test("opening activity over a thread clears the thread", async () => {
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, AGENT_PUBKEY);
+ assert.equal(state.openThreadHeadId, null);
+});
+
+test("opening a thread over activity clears the agent session", async () => {
+ const { openedThreads, run, state } = await renderAgentSessionHandlers();
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: THREAD_HEAD_ID }),
+ );
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.deepEqual(openedThreads, [THREAD_HEAD_ID]);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
+
+test("either ordering ends with exactly one surface's state live", async () => {
+ // Both directions back to back with no close in between: whichever handler ran
+ // last is the only one holding state, so the surface resolver never sees two
+ // candidates and its priority tie-break never has to decide.
+ const { run, state } = await renderAgentSessionHandlers();
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: THREAD_HEAD_ID }),
+ );
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, AGENT_PUBKEY);
+ assert.equal(state.openThreadHeadId, null);
+});
+
+test("back from activity opened over a thread returns to that thread", async () => {
+ // The replacement clears the thread, so the breadcrumb is what keeps it
+ // recoverable; without it, last-opened-wins would be a one-way door.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) => handlers.backFromAgentSession());
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
+
+test("back never resurrects a thread from an earlier replacement", async () => {
+ // Alternating both directions leaves one breadcrumb, not a stack:
+ // `openThreadAndCloseAgentSession` clears the recorded target, so the next
+ // activity open records the thread actually on screen.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: OTHER_THREAD_HEAD_ID }),
+ );
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) => handlers.backFromAgentSession());
+
+ assert.equal(state.openThreadHeadId, OTHER_THREAD_HEAD_ID);
+});
+
+test("an unresolved thread edit blocks the replacement entirely", async () => {
+ // Last-opened-wins runs *after* the thread edit guard `#6575` added, so a
+ // refused open must leave both surfaces exactly as they were — the thread
+ // still on screen with its draft, and no half-applied replacement that
+ // cleared the thread before the guard turned the activity open away.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ requireThreadEditResolution: () => false,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
diff --git a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts b/desktop/src/features/channels/ui/useCoverDrawerPresence.ts
similarity index 68%
rename from desktop/src/features/channels/ui/useFocusDrawerPresence.ts
rename to desktop/src/features/channels/ui/useCoverDrawerPresence.ts
index 271c867ae39..3d725cc979b 100644
--- a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts
+++ b/desktop/src/features/channels/ui/useCoverDrawerPresence.ts
@@ -1,9 +1,9 @@
import * as React from "react";
-import { subscribeToFocusedThreadCloseRequest } from "@/features/channels/focusedThreadCloseRequest";
+import { subscribeToCoverDrawerCloseRequest } from "@/features/channels/coverDrawerCloseRequest";
/** Keeps the covered channel inert and owns external dismissal while open. */
-export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
+export function useCoverDrawerPresence(open: boolean, onClose: () => void) {
const [present, setPresent] = React.useState(false);
React.useEffect(() => {
@@ -12,7 +12,7 @@ export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
React.useEffect(() => {
if (!open) return;
- return subscribeToFocusedThreadCloseRequest(onClose);
+ return subscribeToCoverDrawerCloseRequest(onClose);
}, [onClose, open]);
const markExitComplete = React.useCallback(() => setPresent(false), []);
diff --git a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
index 8dd41cfc51e..5fdb9c62ddf 100644
--- a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
+++ b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
@@ -1,5 +1,6 @@
import * as React from "react";
+import { releaseCoverDrawerFocus } from "@/features/channels/lib/coverDrawerFocusSlot";
import {
setThreadViewMode,
type ThreadViewMode,
@@ -89,6 +90,10 @@ export function useThreadViewModeSwitch({
);
onModeChange?.(mode);
setThreadViewMode(mode);
+ // Changing presentation is not a dismissal: this function decides where
+ // focus goes below, so release the cover drawer's focus slot to stop the
+ // outgoing drawer's own deferred restore from overriding that choice.
+ releaseCoverDrawerFocus();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document
diff --git a/desktop/src/shared/hooks/useEscapeKey.ts b/desktop/src/shared/hooks/useEscapeKey.ts
index 04220027461..fe51389fdb3 100644
--- a/desktop/src/shared/hooks/useEscapeKey.ts
+++ b/desktop/src/shared/hooks/useEscapeKey.ts
@@ -1,3 +1,4 @@
+import { useIsPresent } from "motion/react";
import * as React from "react";
import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
@@ -11,13 +12,29 @@ import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
* app-level Escape shortcuts (mark channel read) know to yield instead
* of racing this listener on registration order.
*
+ * A surface animating out under `AnimatePresence` stands down: it stays
+ * registered (so background shortcuts keep yielding for the duration) but no
+ * longer acts on the key. `AnimatePresence` keeps a replaced surface mounted
+ * through its exit, so during a replacement two surfaces listen at once, and
+ * the outgoing one registered first — it would mark the press
+ * `defaultPrevented` and its successor, respecting exactly that flag, would
+ * ignore it. The user sees a swallowed keypress and has to press Escape twice.
+ * Outside `AnimatePresence` there is no presence context and this is always
+ * true, so surfaces that never animate out are unaffected.
+ *
* Pass `enabled: false` to skip registering the listener entirely.
*/
export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
+ // Read through a ref rather than an effect dependency so entering the exit
+ // phase does not release and re-acquire the surface registration.
+ const isPresentRef = React.useRef(true);
+ isPresentRef.current = useIsPresent();
+
React.useEffect(() => {
if (!enabled) return;
const releaseSurface = acquireEscapeSurface();
function handleKeyDown(event: KeyboardEvent) {
+ if (!isPresentRef.current) return;
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
onEscape();
diff --git a/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
index 4ed0a211e54..242f58d1529 100644
--- a/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
+++ b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
@@ -6,8 +6,11 @@ import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/activity-scope-label";
const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
+// Long enough to overflow the widest presentation the pane has (the cover
+// drawer, which is far wider than the split pane), so the truncation assertion
+// below measures real clamping rather than an accident of panel width.
const LONG_AGENT_NAME =
- "Observer Agent With An Exceptionally Long Display Name";
+ "Observer Agent With An Exceptionally Long Display Name That Keeps Going Well Past Any Reasonable Header Width";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
// Open the activity pane via profile → "View activity" (same ingress the
diff --git a/desktop/tests/e2e/agent-activity-cover-screenshots.spec.ts b/desktop/tests/e2e/agent-activity-cover-screenshots.spec.ts
new file mode 100644
index 00000000000..9ae78b738d7
--- /dev/null
+++ b/desktop/tests/e2e/agent-activity-cover-screenshots.spec.ts
@@ -0,0 +1,490 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { waitForAnimations } from "../helpers/animations";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+
+const SHOTS = "test-results/agent-activity-cover";
+
+const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
+const HUMAN_PUBKEY = TEST_IDENTITIES.bob.pubkey;
+const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
+const SESSION_ID = "session-cover-001";
+const TURN_ID = "turn-cover-001";
+
+/**
+ * The width the drawer is designed for: two panes fit, so activity covers, and
+ * the drawer gets the channel content area less the sliver. Anything narrower
+ * is a different presentation with its own spec
+ * (`agent-activity-cover.spec.ts`), so the reference shot is taken here.
+ */
+const DRAWER_VIEWPORT = { width: 1440, height: 900 };
+
+const MANAGED_AGENTS = [
+ {
+ pubkey: AGENT_PUBKEY,
+ name: "Observer Agent",
+ status: "running" as const,
+ channelNames: ["agents"],
+ },
+];
+
+/**
+ * Anchor the turn just before "now" so the header's relative recency label
+ * ("Last updated 1m ago") reads like a live session rather than an archive.
+ * Absolute timestamps inside the transcript are not asserted, so a moving
+ * anchor costs nothing and keeps the reference shot honest.
+ */
+const TURN_START_MS = Date.now() - 90_000;
+const at = (offsetSeconds: number) =>
+ new Date(TURN_START_MS + offsetSeconds * 1_000).toISOString();
+
+type ObserverEventSeed = {
+ seq: number;
+ timestamp: string;
+ kind: string;
+ agentIndex: number | null;
+ channelId: string | null;
+ sessionId: string | null;
+ turnId: string | null;
+ payload: unknown;
+};
+
+let seq = 0;
+
+function sessionUpdate(
+ offsetSeconds: number,
+ update: unknown,
+): ObserverEventSeed {
+ seq += 1;
+ return {
+ seq,
+ timestamp: at(offsetSeconds),
+ kind: "acp_read",
+ agentIndex: 0,
+ channelId: CHANNEL_ID,
+ sessionId: SESSION_ID,
+ turnId: TURN_ID,
+ payload: {
+ jsonrpc: "2.0",
+ method: "session/update",
+ params: { sessionId: SESSION_ID, update },
+ },
+ };
+}
+
+/**
+ * A tool call as the harness actually reports it: an `in_progress` announcement
+ * followed by a terminal update carrying the output. Seeding only the terminal
+ * update would skip the correlation path the transcript uses to pair them.
+ */
+function toolCall(
+ offsetSeconds: number,
+ input: {
+ args: Record;
+ failed?: boolean;
+ id: string;
+ output: string;
+ title: string;
+ toolName: string;
+ },
+): ObserverEventSeed[] {
+ return [
+ sessionUpdate(offsetSeconds, {
+ sessionUpdate: "tool_call",
+ rawInput: input.args,
+ status: "in_progress",
+ title: input.title,
+ toolCallId: input.id,
+ toolName: input.toolName,
+ }),
+ sessionUpdate(offsetSeconds + 1, {
+ content: [
+ { type: "content", content: { type: "text", text: input.output } },
+ ],
+ rawInput: input.args,
+ sessionUpdate: "tool_call_update",
+ status: input.failed ? "failed" : "completed",
+ title: input.title,
+ toolCallId: input.id,
+ toolName: input.toolName,
+ }),
+ ];
+}
+
+/**
+ * One finished turn with the shape a real investigation has: a mention that
+ * starts it, thinking, 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 is meant to make readable.
+ */
+function buildTurnEvents(): ObserverEventSeed[] {
+ seq = 0;
+ const events: ObserverEventSeed[] = [];
+
+ seq += 1;
+ events.push({
+ seq,
+ timestamp: at(0),
+ kind: "acp_write",
+ agentIndex: 0,
+ channelId: CHANNEL_ID,
+ sessionId: SESSION_ID,
+ turnId: TURN_ID,
+ payload: {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "session/prompt",
+ params: {
+ prompt: [
+ {
+ type: "text",
+ text: [
+ "[Buzz event: @mention]",
+ "Event ID: 4f1c8e6d2b7a90c3e5148af6b0d29c73518ea4d6c09b7f2318ad45e6019cb372",
+ "Channel: agents (#94a444a4-c0a3-5966-ab05-530c6ddc2301)",
+ "Kind: 9",
+ `From: bob (npub: npub1hv32jnyjyr9dwmlagvvsejul4j4ushx2vph2ghde5ktxxu6hlxcqzt5qsn, hex: ${HUMAN_PUBKEY})`,
+ "Time: 2026-08-24T18:04:11+00:00",
+ "Content: @Observer Agent the mention badge lands on the wrong channel row after a reconnect. Trace where the feed category is set and confirm whether the singular/plural mismatch is the cause. Post what you find here.",
+ ].join("\n"),
+ },
+ {
+ type: "text",
+ text: "[Thread context]\nThis is the thread history with 3 prior messages.",
+ },
+ ],
+ },
+ },
+ });
+
+ events.push(
+ sessionUpdate(4, {
+ sessionUpdate: "agent_thought_chunk",
+ messageId: "thought-1",
+ content: {
+ type: "text",
+ text: "The badge is driven by the feed category on the alert event, so a mismatch would show up where that string is built. Start at the emit site, then follow the value into the sidebar row selector.",
+ },
+ }),
+ );
+
+ events.push(
+ ...toolCall(12, {
+ args: { path: "desktop/src/features/feed/lib/feedCategory.ts" },
+ id: "call-read-1",
+ output: 'export type FeedCategory = "mention" | "reply" | "reaction";',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ ...toolCall(15, {
+ args: { path: "desktop/src/features/feed/lib/alertRouting.ts" },
+ id: "call-read-2",
+ output: 'if (category === "mentions") { routeToChannel(channelId); }',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ ...toolCall(18, {
+ args: { path: "desktop/src/features/channels/ui/ChannelRowBadge.tsx" },
+ id: "call-read-3",
+ output: 'const hasMention = categories.includes("mention");',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ );
+
+ events.push(
+ ...toolCall(24, {
+ args: {
+ command: "rg -n 'mentions\"' desktop/src --glob '*.ts' --glob '*.tsx'",
+ },
+ id: "call-shell-1",
+ output: [
+ 'desktop/src/features/feed/lib/alertRouting.ts:41: if (category === "mentions") {',
+ 'desktop/src/features/feed/emitFeedAlert.ts:88: category: "mentions",',
+ 'desktop/src/features/feed/emitFeedAlert.test.mjs:12: category: "mentions",',
+ "",
+ "3 matches across 3 files",
+ ].join("\n"),
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ ...toolCall(31, {
+ args: {
+ command:
+ "buzz messages send --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301 --content 'Confirmed the plural/singular mismatch at the emit site.'",
+ },
+ id: "call-relay-1",
+ output: '{\n "accepted": true,\n "event_id": "a41c9e2f…"\n}',
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ ...toolCall(37, {
+ args: { command: "pnpm vitest run desktop/src/features/feed" },
+ failed: true,
+ id: "call-fail-1",
+ output:
+ "Error: ENOENT: no such file or directory, scandir 'desktop/src/features/feed/__tests__'\n at readdirSync (node:fs:1478:26)\nexit code 1",
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ sessionUpdate(44, {
+ sessionUpdate: "plan",
+ entries: [
+ { content: "Trace the feed category emit site", status: "completed" },
+ {
+ content: "Confirm the sidebar row reads the singular form",
+ status: "completed",
+ },
+ {
+ content: "Add a regression test for the routing branch",
+ status: "pending",
+ },
+ { content: "Open the fix PR", status: "pending" },
+ ],
+ }),
+ );
+
+ events.push(
+ sessionUpdate(52, {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "reply-1",
+ content: {
+ type: "text",
+ text: [
+ 'Confirmed — it is the singular/plural mismatch, and it is on the emit side. `emitFeedAlert` writes `category: "mentions"` while every reader compares against the singular `"mention"`, so the alert never matches the mention branch and falls through to the generic channel-activity path. That is why the badge lands on whichever row was last touched rather than the mentioning channel.',
+ "",
+ "```ts",
+ "// desktop/src/features/feed/emitFeedAlert.ts",
+ '- category: "mentions",',
+ '+ category: "mention",',
+ "```",
+ "",
+ "The reader side needs no change. No fix pushed yet — the feed test directory the suite expects does not exist, so the regression test needs a home first.",
+ ].join("\n"),
+ },
+ }),
+ );
+
+ return events;
+}
+
+async function seedTurn(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
+ ),
+ )
+ .toBe(true);
+ await page.evaluate(
+ ({ evts, pubkey }) => {
+ window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
+ agentPubkey: pubkey,
+ events: evts,
+ });
+ },
+ { evts: buildTurnEvents(), pubkey: AGENT_PUBKEY },
+ );
+}
+
+/**
+ * Composer activity bar → the agent's row.
+ *
+ * This ingress has no prior pane, so the drawer opens with its own close
+ * affordance and no back arrow — the presentation this reference shot is of.
+ */
+async function openActivityFromComposer(page: Page) {
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+
+ await page.evaluate((pubkey) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ });
+ }, AGENT_PUBKEY);
+
+ const trigger = page.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+}
+
+/**
+ * Scroll the transcript to the head of the turn and confirm it stayed there.
+ *
+ * Finds the scrolling ancestor by computed overflow rather than by class name:
+ * the transcript's styling belongs to the transcript variants, which are being
+ * restyled in parallel, and this spec must not break when they change.
+ *
+ * The panel is tail-anchored, and growing its content (by expanding folds) pins
+ * it back to the bottom — so a scroll issued while that is still settling gets
+ * undone a frame later and the shot silently becomes a second tail frame.
+ * Re-issues the scroll and then re-reads it on a later task, so the assertion
+ * only passes once the position actually survives a frame.
+ */
+async function scrollTranscriptToTop(page: Page) {
+ const panel = page.getByTestId("agent-session-thread-panel");
+ const scrollToTopAndSettle = () =>
+ panel.evaluate((element) => {
+ let node = element.querySelector('[role="log"]')?.parentElement ?? null;
+ while (node) {
+ const overflowY = window.getComputedStyle(node).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ node.scrollHeight > node.clientHeight
+ ) {
+ const scroller = node;
+ scroller.scrollTop = 0;
+ // Read back after two frames: a re-pin from the tail anchor lands in
+ // an effect or rAF, so an immediate read would report the write rather
+ // than the outcome.
+ return new Promise((resolve) => {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => resolve(scroller.scrollTop)),
+ );
+ });
+ }
+ node = node.parentElement;
+ }
+ return Promise.resolve(-1);
+ });
+
+ await expect.poll(scrollToTopAndSettle, { timeout: 10_000 }).toBe(0);
+}
+
+/**
+ * Scroll the transcript to the tail of the turn.
+ *
+ * Same scroller discovery as {@link scrollTranscriptToTop}; no re-pin race to
+ * fight here because the tail is where the anchor wants to be anyway.
+ */
+async function scrollTranscriptToBottom(page: Page) {
+ await page.getByTestId("agent-session-thread-panel").evaluate((element) => {
+ let node = element.querySelector('[role="log"]')?.parentElement ?? null;
+ while (node) {
+ const overflowY = window.getComputedStyle(node).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ node.scrollHeight > node.clientHeight
+ ) {
+ node.scrollTop = node.scrollHeight;
+ return;
+ }
+ node = node.parentElement;
+ }
+ });
+}
+
+/**
+ * Open every collapsed row in the transcript.
+ *
+ * The transcript opens with thinking and tool runs folded, so the default frame
+ * is a stack of one-line summaries — true to the product, but it shows none of
+ * the turn's actual shape. Expanding gives the second shot the content the
+ * drawer's width exists for: command output, a failed step, plan items.
+ *
+ * Driven through the native `` `open` property rather than by clicking
+ * a toggle, because the fold's markup and any toggle test id belong to the
+ * transcript variants being restyled in parallel. `open` is the DOM contract
+ * underneath whatever they render, and it survives their changes.
+ */
+async function expandTranscriptRows(page: Page) {
+ const expanded = await page
+ .getByTestId("agent-session-thread-panel")
+ .evaluate((panel) => {
+ const rows = panel.querySelectorAll("details:not([open])");
+ for (const row of rows) {
+ (row as HTMLDetailsElement).open = true;
+ }
+ return rows.length;
+ });
+ // A zero here would mean the folds moved and the shot silently became a
+ // duplicate of the collapsed one, which is the failure worth catching.
+ expect(expanded).toBeGreaterThan(0);
+}
+
+/**
+ * Reference screenshots of a realistic agent turn in the cover drawer.
+ *
+ * The PNGs are the deliverable — they are what design and review look at, and
+ * regenerating them is the point of keeping this spec. So the assertions are
+ * deliberately limited to what Slice A owns: the drawer covers, the panel is
+ * mounted inside it, and there is no split resize handle. Transcript structure,
+ * grouping, and styling belong to the transcript variants and are asserted by
+ * their own specs; asserting them here would make the reference shots fail for
+ * reasons that have nothing to do with the drawer.
+ */
+test.describe("agent activity cover drawer screenshots", () => {
+ test.use({ viewport: DRAWER_VIEWPORT });
+
+ test("realistic turn in the cover drawer", async ({ page }) => {
+ await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ // Seed before opening so the panel has content on its first paint, and
+ // again after: the panel subscribes on mount, and re-seeding is how the
+ // observer store notifies an already-mounted subscriber.
+ await seedTurn(page);
+ await openActivityFromComposer(page);
+ await seedTurn(page);
+
+ const drawer = page.getByTestId("agent-activity-drawer");
+ const panel = page.getByTestId("agent-session-thread-panel");
+ await expect(drawer).toBeVisible();
+ await expect(
+ drawer.getByTestId("agent-session-thread-panel"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("right-auxiliary-pane-resize-handle"),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("channel-drop-zone")).toHaveAttribute(
+ "inert",
+ "",
+ );
+
+ // The turn actually rendered — without this the shots could be of an empty
+ // drawer and still pass every structural assertion above.
+ await expect(
+ page.getByTestId("transcript-user-message").first(),
+ ).toBeVisible({ timeout: 10_000 });
+
+ await waitForAnimations(page);
+ // Full window: the drawer against the sliver and the scrimmed channel,
+ // which is the part of this presentation a panel-only shot cannot show.
+ // Folds are left as the product leaves them — collapsed on open.
+ await page.screenshot({ path: `${SHOTS}/01-turn-in-drawer.png` });
+
+ // Second shot expanded, at the head: this is the frame that shows what the
+ // drawer's width buys — the prompt, thinking, and the reads and shell output
+ // that are invisible while the runs are folded. Scrolled back to the head
+ // because expanding overflows the panel and it is anchored to the tail.
+ await expandTranscriptRows(page);
+ await scrollTranscriptToTop(page);
+ await waitForAnimations(page);
+ await panel.screenshot({ path: `${SHOTS}/02-turn-expanded-head.png` });
+
+ // Third shot, the tail of the same expanded turn: the failed step with its
+ // error, the plan, and the answer with code. Expanded, the turn is taller
+ // than the drawer, so no single frame holds both ends of it.
+ await scrollTranscriptToBottom(page);
+ await waitForAnimations(page);
+ await panel.screenshot({ path: `${SHOTS}/03-turn-expanded-tail.png` });
+ });
+});
diff --git a/desktop/tests/e2e/agent-activity-cover.spec.ts b/desktop/tests/e2e/agent-activity-cover.spec.ts
new file mode 100644
index 00000000000..bdab3565286
--- /dev/null
+++ b/desktop/tests/e2e/agent-activity-cover.spec.ts
@@ -0,0 +1,426 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const AGENT_PUBKEY = TEST_IDENTITIES.alice.pubkey;
+
+/** Two panes fit, so the agent panel covers. */
+const WIDE_VIEWPORT = { width: 1280, height: 800 };
+
+/** Below the two-pane breakpoint, so today's presentation is unchanged. */
+const NARROW_VIEWPORT = { width: 860, height: 800 };
+
+async function waitForMockLiveSubscription(
+ page: Page,
+ channelName: string,
+ kind?: number,
+) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ ({ currentChannelName, currentKind }) =>
+ (
+ window as Window & {
+ __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
+ channelName: string;
+ kind?: number;
+ }) => boolean;
+ }
+ ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+ channelName: currentChannelName,
+ kind: currentKind,
+ }) ?? false,
+ { currentChannelName: channelName, currentKind: kind },
+ ),
+ )
+ .toBe(true);
+}
+
+async function seedThreadRoot(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
+ ),
+ )
+ .toBe(true);
+ return page.evaluate(() => {
+ const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "agents",
+ content: "Cover drawer exclusivity thread",
+ createdAt: 1_700_800_000,
+ });
+ if (!root) throw new Error("Failed to seed thread root");
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "agents",
+ content: "A reply so the thread summary renders.",
+ parentEventId: root.id,
+ createdAt: 1_700_800_001,
+ });
+ return root.id;
+ });
+}
+
+/**
+ * Opens the agent activity panel from the composer activity bar — the ingress
+ * that has no prior pane, so the header shows close and no back arrow.
+ */
+async function openActivityFromComposer(page: Page) {
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+ await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
+
+ await page.evaluate((pubkey) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ });
+ }, AGENT_PUBKEY);
+
+ const trigger = page.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+ await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
+}
+
+/**
+ * There is one covered slot, so at most one drawer overlay may be in the DOM.
+ * Asserts on overlays rather than the drawer surfaces because the overlay is
+ * what makes the channel unreachable — two of them stacked is the failure the
+ * user would actually feel.
+ */
+async function expectExactlyOneCoverDrawer(
+ page: Page,
+ expected: "agent-activity-drawer" | "focus-thread-drawer",
+) {
+ const others = ["agent-activity-drawer", "focus-thread-drawer"].filter(
+ (testId) => testId !== expected,
+ );
+ await expect(page.getByTestId(`${expected}-overlay`)).toHaveCount(1);
+ for (const testId of others) {
+ await expect(page.getByTestId(`${testId}-overlay`)).toHaveCount(0);
+ }
+ await expect(page.getByTestId("channel-drop-zone")).toHaveAttribute(
+ "inert",
+ "",
+ );
+}
+
+/**
+ * Focus must end inside the drawer that won the slot.
+ *
+ * This is a positive check, not the regression guard: the covered channel is
+ * `inert`, so a wrongly-restored focus into it is silently refused by the
+ * browser and lands on `` instead of visibly stealing focus. The
+ * discriminating assertions for the handoff live in
+ * `CoverDrawerFocusHandoff.test.mjs`, which drives the primitive directly.
+ * Polls because both the successor's capture and the loser's deferred restore
+ * land asynchronously.
+ */
+async function expectFocusInside(page: Page, testId: string) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (currentTestId) =>
+ document
+ .querySelector(`[data-testid="${currentTestId}"]`)
+ ?.contains(document.activeElement) ?? false,
+ testId,
+ ),
+ )
+ .toBe(true);
+}
+
+/**
+ * Opens a thread while activity covers the channel.
+ *
+ * The covered channel is inert, so its thread summaries cannot be clicked. A
+ * `messageId` deep link reaches the same place: `useChannelRouteTarget` closes
+ * the agent session and opens the thread in one navigation, which is exactly the
+ * open-over-open transition under test — no closed intermediate state.
+ *
+ * The router uses hash history, so the param has to be written into the hash
+ * fragment (see the same technique in `scroll-history.spec.ts`); rewriting
+ * `location.search` would leave the router none the wiser.
+ */
+async function openThreadByMessageLink(page: Page, threadHeadId: string) {
+ await page.evaluate((targetId) => {
+ const hash = window.location.hash.replace(/^#/, "") || "/";
+ const [path, query = ""] = hash.split("?");
+ const params = new URLSearchParams(query);
+ params.set("messageId", targetId);
+ window.history.pushState(
+ {},
+ "",
+ `${window.location.pathname}#${path}?${params.toString()}`,
+ );
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ }, threadHeadId);
+}
+
+/**
+ * Opens activity while a thread covers the channel, from the thread composer's
+ * own activity bar — the one ingress that is reachable while the channel behind
+ * is inert, so the thread never closes first.
+ */
+async function openActivityFromThreadComposer(
+ page: Page,
+ threadHeadId: string,
+) {
+ await page.evaluate(
+ ({ currentThreadHeadId, pubkey }) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ threadHeadId: currentThreadHeadId,
+ });
+ },
+ { currentThreadHeadId: threadHeadId, pubkey: AGENT_PUBKEY },
+ );
+
+ const drawer = page.getByTestId("focus-thread-drawer");
+ const trigger = drawer.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+}
+
+/**
+ * Opens activity over a covering thread by URL param.
+ *
+ * Reaches the same open handler as the thread-composer ingress, but through a
+ * navigation rather than a Radix popover — so the page holds no dismissable
+ * layer of its own and a press during the overlap can be attributed to the
+ * cover surfaces alone.
+ */
+async function openActivityByParam(page: Page, channelId: string) {
+ await page.evaluate(
+ ({ currentChannelId, pubkey }) => {
+ const hash = window.location.hash.replace(/^#/, "") || "/";
+ const [path, query = ""] = hash.split("?");
+ const params = new URLSearchParams(query);
+ params.delete("messageId");
+ params.delete("thread");
+ params.set("agentSession", pubkey);
+ params.set("agentSessionChannel", currentChannelId);
+ window.history.pushState(
+ {},
+ "",
+ `${window.location.pathname}#${path}?${params.toString()}`,
+ );
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ },
+ { currentChannelId: channelId, pubkey: AGENT_PUBKEY },
+ );
+}
+
+/**
+ * The default mock bridge already seeds alice as an agent in `#agents`, which
+ * is what makes her eligible for the composer activity bar once she types.
+ * Re-seeding her through `managedAgents` instead *replaces* that relay-agent
+ * row with a managed one that is not in the channel's working-agent set, so the
+ * trigger never renders — use the default seed.
+ */
+test.beforeEach(async ({ page }) => {
+ await installMockBridge(page);
+});
+
+test("agent activity covers the channel at wide viewports", async ({
+ page,
+}) => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.goto("/");
+ await openActivityFromComposer(page);
+
+ const channel = page.getByTestId("channel-drop-zone");
+ const drawer = page.getByTestId("agent-activity-drawer");
+ const panel = page.getByTestId("agent-session-thread-panel");
+
+ // Covering, not splitting: the panel lives inside the drawer, the channel is
+ // inert behind it, and there is no split pane to resize.
+ await expect(drawer).toBeVisible();
+ await expect(drawer.getByTestId("agent-session-thread-panel")).toBeVisible();
+ await expect(channel).toHaveAttribute("inert", "");
+ await expect(
+ page.getByTestId("right-auxiliary-pane-resize-handle"),
+ ).toHaveCount(0);
+
+ // Activity never offers the thread's focus/split switch.
+ await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
+
+ // The drawer owns the entrance, so the panel must not slide too — a second
+ // animation inside a moving container compounds into a double slide.
+ await expect(panel).not.toHaveClass(/buzz-side-panel-enter/);
+
+ // Wide enough to read a transcript: the drawer takes the channel content
+ // area less the sliver, so it is far wider than the split pane it replaces.
+ const drawerWidth = (await drawer.boundingBox())?.width ?? 0;
+ expect(drawerWidth).toBeGreaterThan(700);
+
+ // The drawer captures focus, and the panel keeps its close affordance.
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean(
+ document
+ .querySelector('[data-testid="agent-activity-drawer"]')
+ ?.contains(document.activeElement),
+ ),
+ ),
+ )
+ .toBe(true);
+ await expect(page.getByTestId("agent-session-back")).toHaveCount(0);
+ await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible();
+
+ // Escape leaves — and the settings menu still gets its own press first.
+ await page.getByTestId("agent-session-settings-menu-trigger").click();
+ await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-session-stop-turn")).toHaveCount(0);
+ await expect(drawer).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(panel).toHaveCount(0);
+ await expect(channel).not.toHaveAttribute("inert", "");
+
+ // The scrim is the click target back to the channel.
+ await openActivityFromComposer(page);
+ await expect(drawer).toBeVisible();
+ await page
+ .getByTestId("agent-activity-drawer-scrim")
+ .click({ position: { x: 24, y: 300 } });
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(channel).not.toHaveAttribute("inert", "");
+});
+
+test("cover drawers replace each other in both directions without stacking", async ({
+ page,
+}) => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.addInitScript(() => {
+ localStorage.setItem("buzz.channels.threadViewMode", "focus");
+ });
+ await page.goto("/");
+ const rootId = await seedThreadRoot(page);
+ await openActivityFromComposer(page);
+
+ const agentDrawer = page.getByTestId("agent-activity-drawer");
+ const threadDrawer = page.getByTestId("focus-thread-drawer");
+ await expect(agentDrawer).toBeVisible();
+ await expectExactlyOneCoverDrawer(page, "agent-activity-drawer");
+
+ // Direction 1: thread opens over activity, with no closed intermediate. The
+ // covered channel is inert so its thread summaries can't be clicked, but a
+ // message link resolves through the same route-target handler, which clears
+ // the agent session and opens the thread in one navigation.
+ await openThreadByMessageLink(page, rootId);
+ await expect(threadDrawer).toBeVisible();
+ await expect(agentDrawer).toHaveCount(0);
+ await expectExactlyOneCoverDrawer(page, "focus-thread-drawer");
+ // The replaced surface's param is gone, not merely outranked.
+ await expect(page).not.toHaveURL(/agentSession=/);
+ await expect(page).toHaveURL(new RegExp(`thread=${rootId}`));
+ await expectFocusInside(page, "focus-thread-drawer");
+
+ // Direction 2: activity opens over the thread, again with no closed
+ // intermediate — the thread drawer's own composer activity bar is live while
+ // it covers, and its trigger calls the same open handler.
+ await openActivityFromThreadComposer(page, rootId);
+ await expect(agentDrawer).toBeVisible();
+ await expect(threadDrawer).toHaveCount(0);
+ await expectExactlyOneCoverDrawer(page, "agent-activity-drawer");
+ await expect(page).not.toHaveURL(new RegExp(`thread=${rootId}`));
+ await expect(page).toHaveURL(/agentSession=/);
+ await expectFocusInside(page, "agent-activity-drawer");
+});
+
+test("a single Escape closes the drawer that replaced another", async ({
+ page,
+}) => {
+ // `AnimatePresence` holds the replaced drawer mounted through its exit
+ // animation, so for a short window (~210ms measured) two surfaces are
+ // listening for Escape at once. The exiting one must stand down at both
+ // layers: the drawer's own capture-phase claim would consume the press with
+ // `stopImmediatePropagation`, and — on activity's path, where the drawer sets
+ // `ownsEscape={false}` and the panel handles the key — the exiting panel's
+ // `preventDefault` would swallow it just as completely, since `useEscapeKey`
+ // ignores an already-`defaultPrevented` event. Either one alone forces a
+ // second press. Deliberately does not wait for the overlap to settle; that
+ // wait is what makes the other tests here blind to this.
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.addInitScript(() => {
+ localStorage.setItem("buzz.channels.threadViewMode", "focus");
+ });
+ await page.goto("/");
+ const rootId = await seedThreadRoot(page);
+ // Both ingresses here are navigations, so the page never holds a Radix layer
+ // of its own — the composer ingress opens a popover that would legitimately
+ // own the next Escape, and it clears at the same time as the outgoing drawer
+ // (both ~233ms measured), leaving no moment where the overlap is live and the
+ // popover is gone. Keeping the page free of dismissable layers is what lets
+ // the press be attributed to the cover surfaces alone.
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+ const channelId = await page.evaluate(() => {
+ const hash = window.location.hash.replace(/^#/, "");
+ return hash.split("?")[0].split("/").pop() ?? "";
+ });
+ await openThreadByMessageLink(page, rootId);
+ await expect(page.getByTestId("focus-thread-drawer")).toBeVisible();
+
+ // Activity replaces the thread. The successor is up, and the outgoing thread
+ // drawer is still mounted mid-exit.
+ await openActivityByParam(page, channelId);
+ await expect(page.getByTestId("agent-activity-drawer")).toBeVisible();
+ expect(
+ await page.getByTestId("focus-thread-drawer-overlay").count(),
+ ).toBeGreaterThan(0);
+ // Nothing but a cover drawer can absorb the press below.
+ await expect(page.locator("[data-radix-popper-content-wrapper]")).toHaveCount(
+ 0,
+ );
+
+ // One press, inside that window, and activity is gone.
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("agent-session-thread-panel")).toHaveCount(0);
+ await expect(page.getByTestId("channel-drop-zone")).not.toHaveAttribute(
+ "inert",
+ "",
+ );
+ await expect(page).not.toHaveURL(/agentSession=/);
+});
+
+test("narrow viewports keep the existing activity presentation", async ({
+ page,
+}) => {
+ await page.setViewportSize(NARROW_VIEWPORT);
+ await page.goto("/");
+ await openActivityFromComposer(page);
+
+ await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
+ await expect(page.getByTestId("agent-activity-drawer")).toHaveCount(0);
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("agent-activity-drawer-scrim")).toHaveCount(0);
+ // Below the breakpoint the channel is replaced rather than covered, so the
+ // pane it would be made inert behind is not rendered at all — and nothing
+ // else on the page is inert either.
+ await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0);
+ expect(await page.locator("[inert]").count()).toBe(0);
+});