Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default defineConfig({
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
"**/agent-viewer-focus-mode.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
Expand Down
94 changes: 94 additions & 0 deletions desktop/src/features/channels/lib/focusDrawerSurface.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveFocusDrawerSurface } from "./focusDrawerSurface.ts";

const SPLIT_WITH_AGENT = {
channelManagementOpen: false,
hasAgentSession: true,
hasThread: false,
isFocusPreferred: true,
useSplitAuxiliaryPane: true,
};

test("the selected agent surface is focus-eligible", () => {
assert.equal(resolveFocusDrawerSurface(SPLIT_WITH_AGENT), "agent-session");
});

test("split remains the default presentation for both surfaces", () => {
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
isFocusPreferred: false,
}),
null,
);
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
hasAgentSession: false,
hasThread: true,
isFocusPreferred: false,
}),
null,
);
});

test("narrow and overlay presentations are unchanged by focus mode", () => {
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
useSplitAuxiliaryPane: false,
}),
null,
);
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
hasAgentSession: false,
hasThread: true,
useSplitAuxiliaryPane: false,
}),
null,
);
});

test("no drawer without a surface to put in it", () => {
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
hasAgentSession: false,
}),
null,
);
});

test("precedence matches the render chain so the drawer cannot disagree with it", () => {
// ChannelPane renders management → thread → agent session. A thread and an
// agent session can both be resolvable at once (the panel keeps its selected
// agent while a thread opens), and only the thread renders.
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
hasThread: true,
}),
"thread",
);
// Channel management keeps its split pane in every view mode, so it outranks
// both drawers rather than being routed into one.
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
channelManagementOpen: true,
hasThread: true,
}),
null,
);
assert.equal(
resolveFocusDrawerSurface({
...SPLIT_WITH_AGENT,
channelManagementOpen: true,
}),
null,
);
});
62 changes: 62 additions & 0 deletions desktop/src/features/channels/lib/focusDrawerSurface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Which auxiliary surface owns the focus-mode drawer.
*
* Focus mode is a *presentation* of the right-hand auxiliary region, not a
* thread feature: the channel agent work viewer honours the same
* `threadViewMode` preference and reuses the same drawer. Only one surface can
* hold the drawer at a time, so the winner has to be resolved from the same
* precedence the render chain uses — otherwise the drawer and the split pane
* disagree about who is on screen.
*/

/** `AnimatePresence` key shared by both agent-session layouts.
*
* The split pane and the focus drawer are two containers for one viewer, so
* presence belongs to the viewer rather than either container — keying them
* apart would make every layout switch read as a close followed by an open.
* Mirrors `THREAD_SURFACE_KEY`.
*/
export const AGENT_SESSION_SURFACE_KEY = "agent-session-surface";

export type FocusDrawerSurface = "agent-session" | "thread";

type FocusDrawerSurfaceOptions = {
/**
* Channel management is open *and* has a channel to manage — pass the same
* conjunction the render chain branches on, since it outranks both drawers.
*/
channelManagementOpen: boolean;
/** An agent work viewer has a resolved agent and channel to render. */
hasAgentSession: boolean;
/** A thread panel or its skeleton is on screen. */
hasThread: boolean;
/** The persisted `threadViewMode` preference selects the drawer. */
isFocusPreferred: boolean;
/** False in the narrow single-column and overlay presentations. */
useSplitAuxiliaryPane: boolean;
};

/**
* Resolves the focus drawer's occupant, or `null` when nothing should overlay.
*
* Precedence deliberately mirrors `ChannelPane`'s auxiliary render chain
* (management → thread → agent session). Keeping one ordering means a surface
* cannot be routed into the drawer while a higher-precedence surface is what
* actually renders.
*/
export function resolveFocusDrawerSurface({
channelManagementOpen,
hasAgentSession,
hasThread,
isFocusPreferred,
useSplitAuxiliaryPane,
}: FocusDrawerSurfaceOptions): FocusDrawerSurface | null {
// The narrow and overlay presentations are unchanged by focus mode: they
// already fill the column, so there is no channel left to dim behind a scrim.
if (!isFocusPreferred || !useSplitAuxiliaryPane) return null;
// Channel management keeps its split pane in every view mode.
if (channelManagementOpen) return null;
if (hasThread) return "thread";
if (hasAgentSession) return "agent-session";
return null;
}
58 changes: 55 additions & 3 deletions desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
Expand Down Expand Up @@ -67,6 +68,19 @@ type AgentSessionThreadPanelProps = {
channel: Channel | null;
channelId?: string | null;
canInterruptTurn: boolean;
/** Constrains the reading column when presented as the focus drawer. */
columnMaxWidthPx?: number;
/** Panel-owned control rendered ahead of the title (the layout toggle). */
headerLeading?: React.ReactNode;
/**
* True when the panel is the focus-mode drawer's occupant.
*
* The drawer owns the panel's dismissal affordances, so this also turns on
* Escape (the drawer is modal over the channel), drops the header back arrow
* (the scrim sliver is the way back) and suppresses the panel's own slide-in
* so it does not compound with the drawer's.
*/
isFocusMode?: boolean;
layout?: "standalone" | "split";
isSinglePanelView?: boolean;
profiles?: UserProfileLookup;
Expand All @@ -83,11 +97,31 @@ type AgentSessionThreadPanelProps = {
transparentChrome?: boolean;
};

/**
* Scroll region of the agent work viewer.
*
* Named so the shared view-mode switch can read the top-visible transcript row
* out of it, the same way it reads the thread body.
*/
export const AGENT_SESSION_BODY_TEST_ID = "agent-session-transcript-body";

/**
* Centers the transcript column when a `columnMaxWidthPx` is supplied.
*
* Matches the thread panel's focus-mode column so the two drawers present one
* reading measure. The panel's own `px-3` body gutter already handles the split
* pane, so the wider inline padding only applies to the constrained column.
*/
const AGENT_SESSION_COLUMN_CLASS = "mx-auto w-full px-7";

export function AgentSessionThreadPanel({
agent,
canInterruptTurn,
channel,
channelId = null,
columnMaxWidthPx,
headerLeading,
isFocusMode = false,
layout = "standalone",
isSinglePanelView = false,
profiles,
Expand All @@ -98,6 +132,7 @@ export function AgentSessionThreadPanel({
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
const hasConstrainedColumn = columnMaxWidthPx != null;
const sessionChannelId = channelId ?? channel?.id ?? null;
// Unified working signal, scoped to this panel's channel (or all channels
// when the panel is unscoped) — observer turns primary, typing fallback.
Expand All @@ -106,7 +141,9 @@ export function AgentSessionThreadPanel({
sessionChannelId,
);
const canStopCurrentTurn = isWorking && canInterruptTurn;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
// Focus mode is modal over the channel, so it owns Escape the same way the
// narrow and overlay presentations do. The split pane leaves Escape alone.
useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode);

const scrollRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -416,7 +453,11 @@ export function AgentSessionThreadPanel({
align="start"
backButtonAriaLabel="Back from activity"
backButtonTestId="agent-session-back"
onBack={onBack}
leading={headerLeading}
// The focus drawer's scrim sliver is its way back to the channel, so the
// header arrow would be a second, competing exit. Mirrors the thread
// panel's focus-mode header.
onBack={isFocusMode ? undefined : onBack}
>
<ProfileAvatar
avatarUrl={agentProfile?.avatarUrl ?? null}
Expand Down Expand Up @@ -458,6 +499,9 @@ export function AgentSessionThreadPanel({

return (
<AuxiliaryPanel
// The focus drawer animates itself; a second slide here would compound
// into a double entrance.
enterMotion={!isFocusMode}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
Expand All @@ -476,12 +520,20 @@ export function AgentSessionThreadPanel({
>
<AuxiliaryPanelBody
ref={scrollRef}
data-testid={AGENT_SESSION_BODY_TEST_ID}
onScroll={onScroll}
className="overflow-y-auto px-3 pb-4"
panelPadding
tabIndex={-1}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
<div ref={contentRef}>
<div
className={cn(hasConstrainedColumn && AGENT_SESSION_COLUMN_CLASS)}
ref={contentRef}
style={
hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined
}
>
<ManagedAgentSessionPanel
agent={agent}
channelId={sessionChannelId}
Expand Down
Loading
Loading