Skip to content
Merged
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
16 changes: 16 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ test("focus idle drawers yield to every higher-priority auxiliary surface", () =
}
});

test("an explicit thread override keeps the idle panel in its own focus drawer", () => {
assert.equal(
shouldUseFocusIdleDrawer({
channelManagementOpen: false,
hasAgentSession: false,
hasIdleAuxiliaryPanel: true,
hasIdlePanelCloseHandler: true,
hasProfilePanel: false,
hasThreadSurface: true,
overrideThread: true,
useSplitAuxiliaryPane: false,
}),
true,
);
});

test("getChannelIntroKind names project homes ahead of regular streams", () => {
assert.equal(getChannelIntroKind(channel(), true), "project channel");
assert.equal(getChannelIntroKind(channel(), false), "regular channel");
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function shouldUseFocusIdleDrawer({
hasIdlePanelCloseHandler,
hasProfilePanel,
hasThreadSurface,
overrideThread = false,
useSplitAuxiliaryPane,
}: {
channelManagementOpen: boolean;
Expand All @@ -18,14 +19,15 @@ export function shouldUseFocusIdleDrawer({
hasIdlePanelCloseHandler: boolean;
hasProfilePanel: boolean;
hasThreadSurface: boolean;
overrideThread?: boolean;
useSplitAuxiliaryPane: boolean;
}): boolean {
return (
useSplitAuxiliaryPane &&
(useSplitAuxiliaryPane || overrideThread) &&
!channelManagementOpen &&
!hasAgentSession &&
!hasProfilePanel &&
!hasThreadSurface &&
(!hasThreadSurface || overrideThread) &&
hasIdleAuxiliaryPanel &&
hasIdlePanelCloseHandler
);
Expand Down
69 changes: 43 additions & 26 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
ThreadPanelSurface,
useThreadPanelSurface,
} from "@/features/channels/ui/ThreadPanelSurface";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
Expand Down Expand Up @@ -419,10 +423,10 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
const hasThreadSurface =
Boolean(threadHeadMessage) || shouldShowThreadSkeleton;
const useFocusThreadDrawer =
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
threadViewMode === "focus" && useSplitAuxiliaryPane && hasThreadSurface;
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
Expand All @@ -435,19 +439,26 @@ export const ChannelPane = React.memo(function ChannelPane({
);
const hasIdleAuxiliary =
Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel);
const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary(
idleAuxiliaryOverridesThread,
hasIdleAuxiliary,
);
const overlayIdleAuxiliaryOverThread =
priorityIdleAuxiliary && hasThreadSurface && !isOverlay;
const replaceThreadWithIdleAuxiliary =
priorityIdleAuxiliary && hasThreadSurface && isOverlay;
const useFocusIdleDrawer = shouldUseFocusIdleDrawer({
channelManagementOpen,
hasAgentSession: Boolean(activeChannel && selectedAgent),
hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel),
hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel),
hasProfilePanel: Boolean(profilePanelPubkey),
hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton,
hasThreadSurface,
overrideThread: overlayIdleAuxiliaryOverThread,
useSplitAuxiliaryPane,
});
const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary(
idleAuxiliaryOverridesThread,
hasIdleAuxiliary,
);
const showIdleAuxiliaryOverThread =
overlayIdleAuxiliaryOverThread && useFocusIdleDrawer;
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer || useFocusIdleDrawer,
priorityIdleAuxiliary
Expand All @@ -456,6 +467,10 @@ export const ChannelPane = React.memo(function ChannelPane({
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
);
const threadSurface = useThreadPanelSurface(
showIdleAuxiliaryOverThread,
markExitComplete,
);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
useThreadViewModeSwitch({
activeThreadHeadId: threadHeadMessage?.id ?? null,
Expand Down Expand Up @@ -506,26 +521,27 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
<React.Fragment key={options.key ?? testId}>{panel}</React.Fragment>
);
const wrapThreadPanel = (panel: React.ReactNode) =>
useFocusThreadDrawer ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
hasActiveEdit={threadEditTarget !== null}
key={THREAD_SURFACE_KEY}
onClose={onCloseThread}
>
{panel}
</FocusThreadDrawer>
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
const wrapThreadPanel = (panel: React.ReactNode) => (
<ThreadPanelSurface
channelName={activeChannel?.name ?? "channel"}
covered={threadSurface.covered}
hasActiveEdit={threadEditTarget !== null}
isFocusDrawer={useFocusThreadDrawer}
key={THREAD_SURFACE_KEY}
onClose={onCloseThread}
ref={threadSurface.ref}
>
{useFocusThreadDrawer ? panel : wrapAux(panel, "message-thread-panel")}
</ThreadPanelSurface>
);
const wrapIdlePanel = (panel: React.ReactNode) =>
useFocusIdleDrawer && onCloseIdleAuxiliaryPanel ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
key="idle-auxiliary-surface"
label={idleAuxiliaryTitle || "Panel"}
onClose={onCloseIdleAuxiliaryPanel}
restoreFocusTarget={threadSurface.restoreFocusTarget}
>
{panel}
</FocusThreadDrawer>
Expand Down Expand Up @@ -779,10 +795,8 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
{/* The activity accessory is anchored in the dock's reserved
bottom rail, so fading it cannot change the observed
overlay height or move the conversation. Its natural
content height remains responsive. */}
{/* The reserved bottom rail keeps accessory fades from moving
the conversation while content remains responsive. */}
<ChannelComposerActivityAccessory
agents={activityAgents}
channel={activeChannel}
Expand Down Expand Up @@ -821,7 +835,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : priorityIdleAuxiliary && idleAuxiliarySurface ? (
) : replaceThreadWithIdleAuxiliary && idleAuxiliarySurface ? (
idleAuxiliarySurface
) : threadHeadMessage ? (
(() => {
Expand Down Expand Up @@ -977,6 +991,9 @@ export const ChannelPane = React.memo(function ChannelPane({
idleAuxiliarySurface
)}
</AnimatePresence>
<AnimatePresence onExitComplete={threadSurface.markExitComplete}>
{showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null}
</AnimatePresence>
</div>
);
});
61 changes: 59 additions & 2 deletions desktop/src/features/channels/ui/FocusThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
/** Prevent a covered drawer from handling Escape before its overlay. */
escapeEnabled?: boolean;
/** Accessible name for the drawer. Channel threads leave the default. */
label?: string;
hasActiveEdit?: boolean;
onClose: () => void;
/** Resolve an explicit focus target after this drawer has been dismissed. */
restoreFocusTarget?: () => HTMLElement | null;
};

/**
Expand Down Expand Up @@ -120,6 +124,46 @@ const EXIT_TRANSITION = {
*/
const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;

function useViewportRightInsetPx(
overlayRef: React.RefObject<HTMLDivElement | null>,
) {
const [rightInsetPx, setRightInsetPx] = React.useState(0);

React.useLayoutEffect(() => {
const layoutRoot = overlayRef.current?.parentElement;
if (!layoutRoot) return;

const updateRightInset = () => {
const bounds = layoutRoot.getBoundingClientRect();
const overflowPx = Math.max(0, bounds.right - window.innerWidth);
const nextRightInsetPx = Math.min(bounds.width, Math.ceil(overflowPx));
setRightInsetPx((current) =>
current === nextRightInsetPx ? current : nextRightInsetPx,
);
};

updateRightInset();
window.addEventListener("resize", updateRightInset);

const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateRightInset);
let ancestor: HTMLElement | null = layoutRoot;
while (ancestor) {
observer?.observe(ancestor);
ancestor = ancestor.parentElement;
}

return () => {
observer?.disconnect();
window.removeEventListener("resize", updateRightInset);
};
}, [overlayRef]);

return rightInsetPx;
}

/**
* Right-anchored thread drawer that overlays the channel content area.
*
Expand All @@ -142,16 +186,22 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
export function FocusThreadDrawer({
channelName,
children,
escapeEnabled = true,
label = "Thread",
hasActiveEdit = false,
onClose,
restoreFocusTarget,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
const drawerRef = React.useRef<HTMLDivElement>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
const viewportRightInsetPx = useViewportRightInsetPx(overlayRef);

React.useEffect(() => {
if (!escapeEnabled) return;

function handleEscape(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const target = event.target;
Expand All @@ -171,7 +221,7 @@ export function FocusThreadDrawer({
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [hasActiveEdit, onClose]);
}, [escapeEnabled, hasActiveEdit, onClose]);

React.useLayoutEffect(() => {
previousFocusRef.current =
Expand All @@ -183,19 +233,26 @@ export function FocusThreadDrawer({
return () => {
const previousFocus = previousFocusRef.current;
requestAnimationFrame(() => {
const explicitTarget = restoreFocusTarget?.();
if (explicitTarget) {
explicitTarget.focus({ preventScroll: true });
return;
}
// 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 });
}
});
};
}, []);
}, [restoreFocusTarget]);

return (
<div
className="absolute inset-0 z-41"
data-testid="focus-thread-drawer-overlay"
ref={overlayRef}
style={{ right: viewportRightInsetPx }}
>
<motion.button
animate={{ opacity: 1 }}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function RightAuxiliaryPane({
return (
<aside
className={cn(
"group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background",
"group/right-pane relative isolate flex h-full shrink-0 flex-col overflow-hidden bg-background",
detached
? "bg-transparent"
: "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']",
Expand Down
66 changes: 66 additions & 0 deletions desktop/src/features/channels/ui/ThreadPanelSurface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from "react";

import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
import { usePresenceCoverage } from "@/features/channels/ui/useFocusDrawerPresence";

type ThreadPanelSurfaceProps = {
channelName: string;
children: React.ReactNode;
covered: boolean;
hasActiveEdit: boolean;
isFocusDrawer: boolean;
onClose: () => void;
};

/** Keeps a thread mounted while controlling its focus-drawer presentation. */
export const ThreadPanelSurface = React.forwardRef<
HTMLDivElement,
ThreadPanelSurfaceProps
>(function ThreadPanelSurface(
{ channelName, children, covered, hasActiveEdit, isFocusDrawer, onClose },
ref,
) {
return (
<div
aria-hidden={covered ? true : undefined}
className="contents"
data-testid="thread-surface"
inert={covered ? true : undefined}
ref={ref}
>
{isFocusDrawer ? (
<FocusThreadDrawer
channelName={channelName}
escapeEnabled={!covered}
hasActiveEdit={hasActiveEdit}
onClose={onClose}
>
{children}
</FocusThreadDrawer>
) : (
children
)}
</div>
);
});

/** Supplies covered-thread lifecycle and focus ownership for an overlay drawer. */
export function useThreadPanelSurface(
open: boolean,
onExitComplete: () => void,
) {
const ref = React.useRef<HTMLDivElement>(null);
const coverage = usePresenceCoverage(open);
const markExitComplete = React.useCallback(() => {
coverage.markExitComplete();
onExitComplete();
}, [coverage.markExitComplete, onExitComplete]);
const restoreFocusTarget = React.useCallback(
() =>
ref.current?.querySelector<HTMLElement>(
'[data-testid="auxiliary-panel-close"]',
) ?? null,
[],
);
return { ...coverage, markExitComplete, ref, restoreFocusTarget };
}
Loading
Loading