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

**Category:** feat
**User Impact:** At wide viewports, opening an agent's activity inside a
channel now covers the channel content area in a right-anchored drawer
instead of squeezing into a ~380px split pane, so tool calls, diffs, and
command output are readable without resizing.

**Problem:** The agent session panel shared the thread's split
`RightAuxiliaryPane`, which is too narrow for a transcript. The focus
drawer that solves this for threads was welded to thread-specific
breadcrumb and view-mode-toggle concerns, so activity could not reuse it.

**Solution:** Extract the drawer surface into a presentation-only
`CoverDrawer` and give activity its own thin wrapper. Activity always
covers at wide viewports and offers no focus/split toggle; narrow,
overlay, and single-panel presentations are unchanged for both surfaces.
A single resolved auxiliary surface makes the two drawers mutually
exclusive by construction, so last-opened wins and two drawers can never
stack. Thread drawer behavior is unchanged.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/CoverDrawer.tsx**
New presentation-only drawer extracted from `FocusThreadDrawer`: motion,
scrim, focus capture/restore, and an `ownsEscape` opt-out for content
that already handles Escape itself.

**desktop/src/features/channels/ui/AgentActivityDrawer.tsx**
Activity's wrapper. Delegates Escape to the panel so the settings menu
dismisses first.

**desktop/src/features/channels/ui/FocusThreadDrawer.tsx**
Reduced to a `CoverDrawer` wrapper plus its thread-specific
focus-restore rule. Behavior unchanged.

**desktop/src/features/channels/lib/channelAuxiliarySurface.ts**
Resolves the one auxiliary surface and which surface, if any, covers —
the structural guarantee that only one drawer exists.

**desktop/src/features/channels/lib/agentSessionPanelPresentation.ts**
Maps presentation to the panel's layout props. Suppresses the panel's own
enter motion inside the drawer to avoid a double slide.

**desktop/src/features/channels/lib/coverDrawerLayout.ts**
Sliver width and travel distance, moved off the thread-specific module.

**desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx**
Accepts and forwards `enterMotion` (additive), which `AuxiliaryPanel`
already supported but the panel never threaded through.

**desktop/src/features/channels/ui/ChannelPane.tsx**
Renders branches off the resolved surface instead of an implicit
fall-through chain, and wraps the agent panel in the cover drawer.

**desktop/tests/e2e/agent-activity-cover.spec.ts**
Covers covering vs. splitting, drawer exclusivity, suppressed panel
motion, Escape/scrim dismissal, and unchanged narrow presentation.

**desktop/tests/e2e/activity-scope-label-screenshots.spec.ts**
Lengthens the long-name fixture so the header truncation assertion still
clamps at the wider cover-drawer width.

</details>

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-00
2026-08-21 19:25:24 -07:00
parent f6e6617a9d
commit 88a96d7682
20 changed files with 1143 additions and 339 deletions

View File

@@ -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",

View File

@@ -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)}

View File

@@ -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);
});

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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);
}

View File

@@ -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,
},
);
}
});

View File

@@ -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,
};
}

View File

@@ -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");
});

View File

@@ -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;
}

View File

@@ -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;

View File

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

View File

@@ -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 (
<CoverDrawer
ariaLabel="Agent activity"
onClose={onClose}
ownsEscape={false}
scrimLabel={`Back to #${channelName}`}
testId="agent-activity-drawer"
>
{children}
</CoverDrawer>
);
}

View File

@@ -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 (
<AuxiliaryPanel
enterMotion={enterMotion}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}

View File

@@ -32,12 +32,21 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { AgentActivityDrawer } from "@/features/channels/ui/AgentActivityDrawer";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
import {
AGENT_SESSION_SURFACE_KEY,
getAgentSessionPanelPresentation,
} from "@/features/channels/lib/agentSessionPanelPresentation";
import {
resolveChannelAuxiliarySurface,
resolveChannelCoverDrawer,
} from "@/features/channels/lib/channelAuxiliarySurface";
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout";
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
import { useCoverDrawerPresence } from "@/features/channels/ui/useCoverDrawerPresence";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
@@ -440,13 +449,37 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
const useFocusThreadDrawer =
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer,
onCloseThread,
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
agentSessionAgents,
openAgentSessionPubkey,
profilePanelPubkey,
profiles,
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
// One resolution for both "which panel" and "which presentation", so the two
// cover drawers can never stack: there is a single covered slot and the
// resolved surface owns it.
const auxiliarySurface = resolveChannelAuxiliarySurface({
channelManagementOpen,
hasActiveChannel: Boolean(activeChannel),
hasProfilePanel: Boolean(profilePanelPubkey),
hasSelectedAgent: Boolean(selectedAgent),
hasThreadHead: Boolean(threadHeadMessage),
shouldShowThreadSkeleton,
});
const coverDrawer = resolveChannelCoverDrawer({
surface: auxiliarySurface,
threadViewMode,
useSplitAuxiliaryPane,
});
const useFocusThreadDrawer = coverDrawer === "thread";
const useAgentActivityDrawer = coverDrawer === "agent-session";
const { channelIsCovered, markExitComplete } = useCoverDrawerPresence(
coverDrawer !== null,
useAgentActivityDrawer ? onCloseAgentSession : onCloseThread,
);
const pendingMainEditRef = React.useRef<TimelineMessage | null>(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 ? (
<AgentActivityDrawer
channelName={activeChannel?.name ?? "channel"}
key={AGENT_SESSION_SURFACE_KEY}
onClose={onCloseAgentSession}
>
{panel}
</AgentActivityDrawer>
) : (
wrapAux(panel, "agent-session-thread-panel", {
key: AGENT_SESSION_SURFACE_KEY,
})
);
const threadHeaderLeading = useSplitAuxiliaryPane ? (
<ThreadViewModeToggle onChange={changeThreadViewMode} />
) : 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({
</section>
) : 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.
*/}
<AnimatePresence onExitComplete={markExitComplete}>
{channelManagementOpen && activeChannel ? (
{auxiliarySurface === "channel-management" && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
@@ -850,7 +887,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
) : auxiliarySurface === "thread" && threadHeadMessage ? (
(() => {
const panel = (
<MessageThreadPanel
@@ -924,7 +961,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapThreadPanel(panel);
})()
) : shouldShowThreadSkeleton ? (
) : auxiliarySurface === "thread-skeleton" ? (
(() => {
if (isHuddleTranscript) {
return wrapThreadPanel(<HuddleStartingView />);
@@ -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 = (
<UserProfilePanel

View File

@@ -0,0 +1,301 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import {
COVER_DRAWER_SLIVER_WIDTH_PX,
COVER_DRAWER_TRAVEL_PX,
} from "@/features/channels/lib/coverDrawerLayout";
import { cn } from "@/shared/lib/cn";
type CoverDrawerProps = {
/** Accessible name for the drawer surface itself. */
ariaLabel: string;
children: React.ReactNode;
onClose: () => 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<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(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 (
<div className="absolute inset-0 z-41" data-testid={`${testId}-overlay`}>
<motion.button
animate={{ opacity: 1 }}
aria-label={scrimLabel}
className={cn(
"absolute inset-0 cursor-pointer transition-colors duration-150",
COVER_SCRIM_CLASS,
COVER_SCRIM_HOVER_CLASS,
)}
data-testid={`${testId}-scrim`}
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_EXIT_SECONDS, ease: "linear" },
}}
initial={{ opacity: 0 }}
onClick={onClose}
transition={
prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_ENTER_SECONDS, ease: "linear" }
}
type="button"
/>
<motion.div
animate={{ opacity: 1, x: 0 }}
className={cn(
// Left corners only, at the app content surface's own `rounded-2xl`:
// the drawer is flush to that surface's right edge, so it is *clipped*
// to its right corners rather than nesting inside them. Flush edges
// share a radius — a smaller one here would put two radii on one
// element. `shadow-panel-left` draws the left edge and its corners;
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
aria-label={ariaLabel}
data-testid={testId}
ref={drawerRef}
role="complementary"
tabIndex={-1}
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: EXIT_TRANSITION,
x: travelPx,
}}
initial={{ opacity: 0, x: travelPx }}
style={{ left: COVER_DRAWER_SLIVER_WIDTH_PX }}
transition={
prefersReducedMotion ? REDUCED_MOTION_TRANSITION : ENTER_TRANSITION
}
>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</motion.div>
</div>
);
}

View File

@@ -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<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(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 (
<div
className="absolute inset-0 z-41"
data-testid="focus-thread-drawer-overlay"
<CoverDrawer
ariaLabel="Thread"
escapeYieldsToContent={hasActiveEdit}
onClose={onClose}
scrimLabel={`Back to #${channelName}`}
shouldRestoreFocusOnClose={shouldRestoreFocusOnClose}
testId="focus-thread-drawer"
>
<motion.button
animate={{ opacity: 1 }}
aria-label={`Back to #${channelName}`}
className={cn(
"absolute inset-0 cursor-pointer transition-colors duration-150",
FOCUS_SCRIM_CLASS,
FOCUS_SCRIM_HOVER_CLASS,
)}
data-testid="focus-thread-drawer-scrim"
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_EXIT_SECONDS, ease: "linear" },
}}
initial={{ opacity: 0 }}
onClick={onClose}
transition={
prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_ENTER_SECONDS, ease: "linear" }
}
type="button"
/>
<motion.div
animate={{ opacity: 1, x: 0 }}
className={cn(
// Left corners only, at the app content surface's own `rounded-2xl`:
// the drawer is flush to that surface's right edge, so it is *clipped*
// to its right corners rather than nesting inside them. Flush edges
// share a radius — a smaller one here would put two radii on one
// element. `shadow-panel-left` draws the left edge and its corners;
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
aria-label="Thread"
data-testid="focus-thread-drawer"
ref={drawerRef}
role="complementary"
tabIndex={-1}
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: EXIT_TRANSITION,
x: travelPx,
}}
initial={{ opacity: 0, x: travelPx }}
style={{ left: THREAD_FOCUS_SLIVER_WIDTH_PX }}
transition={
prefersReducedMotion ? REDUCED_MOTION_TRANSITION : ENTER_TRANSITION
}
>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</motion.div>
</div>
{children}
</CoverDrawer>
);
}

View File

@@ -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), []);

View File

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

View File

@@ -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);
});