mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
22
desktop/src/features/channels/coverDrawerCloseRequest.ts
Normal file
22
desktop/src/features/channels/coverDrawerCloseRequest.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
77
desktop/src/features/channels/lib/channelAuxiliarySurface.ts
Normal file
77
desktop/src/features/channels/lib/channelAuxiliarySurface.ts
Normal 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;
|
||||
}
|
||||
34
desktop/src/features/channels/lib/coverDrawerLayout.ts
Normal file
34
desktop/src/features/channels/lib/coverDrawerLayout.ts
Normal 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;
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
43
desktop/src/features/channels/ui/AgentActivityDrawer.tsx
Normal file
43
desktop/src/features/channels/ui/AgentActivityDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
301
desktop/src/features/channels/ui/CoverDrawer.tsx
Normal file
301
desktop/src/features/channels/ui/CoverDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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), []);
|
||||
@@ -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
|
||||
|
||||
234
desktop/tests/e2e/agent-activity-cover.spec.ts
Normal file
234
desktop/tests/e2e/agent-activity-cover.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user