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