From 1cf681e76af0c36546dd13886b2025c976be3b3a Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 26 Aug 2026 16:36:27 -0400 Subject: [PATCH 1/4] fix(desktop): keep project sheets independent from threads Render project workspace sheets as their own right-side overlay while preserving the underlying thread, escape behavior, and resize-layer boundaries. Signed-off-by: Thomas Petersen --- .../channels/ui/ChannelPane.helpers.test.mjs | 16 +++++ .../channels/ui/ChannelPane.helpers.ts | 6 +- .../src/features/channels/ui/ChannelPane.tsx | 29 +++++--- .../channels/ui/FocusThreadDrawer.tsx | 7 +- .../channels/ui/RightAuxiliaryPane.tsx | 2 +- .../tests/e2e/project-commit-detail.spec.ts | 72 ++++++++++++++++++- 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 10a948e7053..9d2cd6a32dd 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -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"); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 695fef166fe..f59568779d1 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -10,6 +10,7 @@ export function shouldUseFocusIdleDrawer({ hasIdlePanelCloseHandler, hasProfilePanel, hasThreadSurface, + overrideThread = false, useSplitAuxiliaryPane, }: { channelManagementOpen: boolean; @@ -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 ); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 0d9a5eeb1ec..4a3180dc8e8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -419,10 +419,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({ @@ -435,19 +435,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 @@ -510,6 +517,7 @@ export const ChannelPane = React.memo(function ChannelPane({ useFocusThreadDrawer ? ( - ) : priorityIdleAuxiliary && idleAuxiliarySurface ? ( + ) : replaceThreadWithIdleAuxiliary && idleAuxiliarySurface ? ( idleAuxiliarySurface ) : threadHeadMessage ? ( (() => { @@ -977,6 +985,9 @@ export const ChannelPane = React.memo(function ChannelPane({ idleAuxiliarySurface )} + + {showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null} + ); }); diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 626dbbddc27..8217b25b81b 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -11,6 +11,8 @@ 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; @@ -142,6 +144,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, + escapeEnabled = true, label = "Thread", hasActiveEdit = false, onClose, @@ -152,6 +155,8 @@ export function FocusThreadDrawer({ const previousFocusRef = React.useRef(null); React.useEffect(() => { + if (!escapeEnabled) return; + function handleEscape(event: KeyboardEvent) { if (event.key !== "Escape") return; const target = event.target; @@ -171,7 +176,7 @@ export function FocusThreadDrawer({ return () => { window.removeEventListener("keydown", handleEscape, { capture: true }); }; - }, [hasActiveEdit, onClose]); + }, [escapeEnabled, hasActiveEdit, onClose]); React.useLayoutEffect(() => { previousFocusRef.current = diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 68f68890bbf..49fd2b58fc1 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -31,7 +31,7 @@ export function RightAuxiliaryPane({ return (