diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 69250c4b53..7c6465cdff 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -95,6 +95,7 @@ export default defineConfig({
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
+ "**/agent-activity-cover.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 e111f93ca0..0cf787647d 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 0000000000..75a3bf70db
--- /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 0000000000..6e8f2b705e
--- /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 6f30d7ec6d..0000000000
--- 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 3628d70767..0000000000
--- 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 0000000000..9294d134a4
--- /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 0000000000..e7380da511
--- /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 0000000000..bbad854850
--- /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 0000000000..7b419e5aa3
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelAuxiliarySurface.ts
@@ -0,0 +1,77 @@
+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 this priority order. The open handlers in
+ * `useChannelAgentSessions` / `useChannelProfilePanel` already clear the other
+ * panels' state so last-opened wins; this resolution is what guarantees that a
+ * lingering search param or a race can still only ever produce one surface.
+ */
+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/coverDrawerLayout.ts b/desktop/src/features/channels/lib/coverDrawerLayout.ts
new file mode 100644
index 0000000000..00aa710f0f
--- /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 f14f3399bd..edfbb3a0cd 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 0000000000..e0121baa44
--- /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 c1933f14bb..ce4ca8d204 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 (
+ 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 +563,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 +597,20 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
+ const wrapAgentSessionPanel = (panel: React.ReactNode) =>
+ useAgentActivityDrawer ? (
+
+ {panel}
+
+ ) : (
+ wrapAux(panel, "agent-session-thread-panel", {
+ key: AGENT_SESSION_SURFACE_KEY,
+ })
+ );
const threadHeaderLeading = useSplitAuxiliaryPane ? (
) : undefined;
@@ -588,6 +620,11 @@ export const ChannelPane = React.memo(function ChannelPane({
isSinglePanelView,
useSplitAuxiliaryPane,
});
+ const agentSessionLayoutProps = getAgentSessionPanelPresentation({
+ isCoverDrawer: useAgentActivityDrawer,
+ isSinglePanelView,
+ useSplitAuxiliaryPane,
+ });
const timelineReplyHandler =
activeChannel?.archivedAt || isHuddleTranscript ? undefined : onOpenThread;
return (
@@ -826,15 +863,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,7 +975,9 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapThreadPanel(panel);
})()
- ) : activeChannel && selectedAgent ? (
+ ) : auxiliarySurface === "agent-session" &&
+ activeChannel &&
+ selectedAgent ? (
(() => {
// When the panel was opened from a different channel than the
// currently active one, re-scope it to the active channel so
@@ -966,20 +1005,16 @@ export const ChannelPane = React.memo(function ChannelPane({
: null
}
channelId={effectiveAgentSessionChannelId}
- isSinglePanelView={
- useSplitAuxiliaryPane ? false : isSinglePanelView
- }
- layout={useSplitAuxiliaryPane ? "split" : "standalone"}
- transparentChrome={useSplitAuxiliaryPane}
+ {...agentSessionLayoutProps}
profiles={profiles}
onBack={onBackFromAgentSession}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
- return wrapAux(panel, "agent-session-thread-panel");
+ return wrapAgentSessionPanel(panel);
})()
- ) : profilePanelPubkey ? (
+ ) : 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;
+ /**
+ * Whether unmounting should hand focus back to wherever it was before the
+ * drawer opened. Evaluated at unmount, so a caller can distinguish a real
+ * dismissal from a presentation switch that has already claimed focus
+ * elsewhere. Defaults to restoring.
+ */
+ shouldRestoreFocusOnClose?: () => boolean;
+ /**
+ * 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,
+ shouldRestoreFocusOnClose,
+ testId,
+}: CoverDrawerProps) {
+ const prefersReducedMotion = useReducedMotion();
+ const travelPx = prefersReducedMotion ? 0 : COVER_DRAWER_TRAVEL_PX;
+ const drawerRef = React.useRef(null);
+ const previousFocusRef = React.useRef(null);
+ // Read at unmount, so the drawer never re-registers its focus effect (and
+ // never re-steals focus) just because the predicate identity changed.
+ const shouldRestoreFocusRef = React.useRef(shouldRestoreFocusOnClose);
+ shouldRestoreFocusRef.current = shouldRestoreFocusOnClose;
+
+ React.useEffect(() => {
+ if (!ownsEscape) 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, onClose, ownsEscape]);
+
+ React.useLayoutEffect(() => {
+ previousFocusRef.current =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ drawerRef.current?.focus({ preventScroll: true });
+
+ return () => {
+ const previousFocus = previousFocusRef.current;
+ const shouldRestoreFocus = shouldRestoreFocusRef.current;
+ requestAnimationFrame(() => {
+ if (shouldRestoreFocus && !shouldRestoreFocus()) return;
+ previousFocus?.focus({ preventScroll: true });
+ });
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
index 410f2d8672..8dcbee8a26 100644
--- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
+++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
@@ -1,141 +1,33 @@
-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.
- *
- * 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.
+ * A real dismissal keeps focus mode selected; a presentation switch has already
+ * selected split mode and owns focus inside the new panel.
*/
-const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
+function shouldRestoreFocusOnClose() {
+ return getThreadViewMode() === "focus";
+}
/**
- * Hover eases the veil one step in both themes.
+ * The focus-mode thread presentation: a {@link CoverDrawer} holding the thread.
*
- * 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 —
+ * lives in `CoverDrawer`. What is thread-specific is the focus-restore rule
+ * above, which has to yield to the view-mode toggle.
*/
export function FocusThreadDrawer({
channelName,
@@ -143,113 +35,16 @@ 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/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 271c867ae3..3d725cc979 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/tests/e2e/activity-scope-label-screenshots.spec.ts b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
index 4ed0a211e5..242f58d152 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.spec.ts b/desktop/tests/e2e/agent-activity-cover.spec.ts
new file mode 100644
index 0000000000..ae4c5e189a
--- /dev/null
+++ b/desktop/tests/e2e/agent-activity-cover.spec.ts
@@ -0,0 +1,234 @@
+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();
+}
+
+/**
+ * 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("only one cover drawer is ever open", 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();
+
+ // Opening a thread from the covered channel is not possible while it is
+ // inert, so drive the same handler the timeline uses: leave activity, then
+ // open the thread. The thread drawer takes the covered slot alone.
+ await page.getByTestId("auxiliary-panel-close").click();
+ await expect(agentDrawer).toHaveCount(0);
+
+ const summary = page.locator(
+ `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
+ );
+ await expect(summary).toBeVisible();
+ await summary.click();
+ await expect(threadDrawer).toBeVisible();
+ await expect(agentDrawer).toHaveCount(0);
+
+ // Now open activity with the thread still in the URL: the agent surface wins
+ // nothing — the thread owns the slot — and crucially there is never more
+ // than one drawer overlay in the DOM.
+ await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(1);
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+
+ // Escape returns the channel, then activity may cover again.
+ await page.keyboard.press("Escape");
+ await expect(threadDrawer).toHaveCount(0);
+ await openActivityFromComposer(page);
+ await expect(agentDrawer).toBeVisible();
+ await expect(page.getByTestId("focus-thread-drawer-overlay")).toHaveCount(0);
+});
+
+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);
+});