diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..a79cd74dfc1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -92,6 +92,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 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 ( agentSessionSelection.resolveSelectedAgentSession({ @@ -478,13 +472,37 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [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 { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = + useThreadViewModeSwitch({ + activeThreadHeadId: threadHeadMessage?.id ?? null, + externalScrollTargetId: threadScrollTargetId, + onExternalTargetResolved: onThreadScrollTargetResolved, + onModeChange: markExitComplete, + }); const hasSplitAuxiliaryPane = - useSplitAuxiliaryPane && - (channelManagementOpen || - Boolean(threadHeadMessage) || - shouldShowThreadSkeleton || - Boolean(activeChannel && selectedAgent) || - Boolean(profilePanelPubkey)); + useSplitAuxiliaryPane && auxiliarySurface !== null; const wrapAux = ( panel: React.ReactNode, testId: string, @@ -516,6 +534,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; @@ -525,6 +557,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 ( @@ -759,15 +796,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(); @@ -869,7 +906,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 @@ -897,20 +936,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; + /** 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, + onClose, + ownsEscape = true, + scrimLabel, + testId, +}: CoverDrawerProps) { + const prefersReducedMotion = useReducedMotion(); + const travelPx = prefersReducedMotion ? 0 : COVER_DRAWER_TRAVEL_PX; + const drawerRef = React.useRef(null); + const previousFocusRef = React.useRef(null); + + React.useEffect(() => { + if (!ownsEscape) return; + + function handleEscape(event: KeyboardEvent) { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + onClose(); + } + + window.addEventListener("keydown", handleEscape, { capture: true }); + return () => { + window.removeEventListener("keydown", handleEscape, { capture: true }); + }; + }, [onClose, ownsEscape]); + + React.useLayoutEffect(() => { + 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 ( +
+ + + +
{children}
+
+
+ ); +} 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..74b94f8a9d7 --- /dev/null +++ b/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs @@ -0,0 +1,165 @@ +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 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 1aaad0e6093..5f793c8eff5 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -1,12 +1,6 @@ -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; @@ -15,231 +9,27 @@ type FocusThreadDrawerProps = { }; /** - * 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, children, 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; - event.preventDefault(); - event.stopImmediatePropagation(); - onClose(); - } - - window.addEventListener("keydown", handleEscape, { capture: true }); - return () => { - window.removeEventListener("keydown", handleEscape, { capture: true }); - }; - }, [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}
-
-
+ {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..b624a346175 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs @@ -0,0 +1,177 @@ +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 } = {}) { + 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, + 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); +}); 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/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.spec.ts b/desktop/tests/e2e/agent-activity-cover.spec.ts new file mode 100644 index 00000000000..214ff9799c2 --- /dev/null +++ b/desktop/tests/e2e/agent-activity-cover.spec.ts @@ -0,0 +1,337 @@ +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 }); +} + +/** + * 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("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); +});