fix(desktop): keep project sheets independent from threads (#6901)

## Summary

Project workspace sheets now slide in from the right as an independent
layer over an open conversation thread instead of replacing the thread
or rendering inside its panel. Closing the sheet restores the unchanged
thread, Escape applies only to the topmost surface, and the covered
thread's resize divider no longer leaks through the overlay.

### Related issue

Related: #6602. No matching issue found.

### Testing

- `pnpm --dir desktop exec biome check
src/features/channels/ui/RightAuxiliaryPane.tsx
tests/e2e/project-commit-detail.spec.ts`
- `pnpm exec playwright test tests/e2e/project-commit-detail.spec.ts
--project=smoke --grep "project workspace sheet stays independent from
an open thread"`
- Pre-push desktop formatting, file-size, typecheck, and unit-test
checks

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-26 20:59:47 -04:00
committed by GitHub
parent cdab765748
commit cada3028ce
8 changed files with 352 additions and 36 deletions

View File

@@ -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");

View File

@@ -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
);

View File

@@ -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";
@@ -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({
@@ -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
@@ -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,
@@ -506,19 +521,19 @@ 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
@@ -526,6 +541,7 @@ export const ChannelPane = React.memo(function ChannelPane({
key="idle-auxiliary-surface"
label={idleAuxiliaryTitle || "Panel"}
onClose={onCloseIdleAuxiliaryPanel}
restoreFocusTarget={threadSurface.restoreFocusTarget}
>
{panel}
</FocusThreadDrawer>
@@ -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}
@@ -821,7 +835,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : priorityIdleAuxiliary && idleAuxiliarySurface ? (
) : replaceThreadWithIdleAuxiliary && idleAuxiliarySurface ? (
idleAuxiliarySurface
) : threadHeadMessage ? (
(() => {
@@ -977,6 +991,9 @@ export const ChannelPane = React.memo(function ChannelPane({
idleAuxiliarySurface
)}
</AnimatePresence>
<AnimatePresence onExitComplete={threadSurface.markExitComplete}>
{showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null}
</AnimatePresence>
</div>
);
});

View File

@@ -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;
};
/**
@@ -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.
*
@@ -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;
@@ -171,7 +221,7 @@ export function FocusThreadDrawer({
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [hasActiveEdit, onClose]);
}, [escapeEnabled, hasActiveEdit, onClose]);
React.useLayoutEffect(() => {
previousFocusRef.current =
@@ -183,6 +233,11 @@ 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") {
@@ -190,12 +245,14 @@ export function FocusThreadDrawer({
}
});
};
}, []);
}, [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 }}

View File

@@ -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-['']",

View File

@@ -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 };
}

View File

@@ -2,22 +2,32 @@ import * as React from "react";
import { subscribeToFocusedThreadCloseRequest } from "@/features/channels/focusedThreadCloseRequest";
/** Keeps the covered channel inert and owns external dismissal while open. */
export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
/** Retains coverage until the owning presence boundary completes its exit. */
export function usePresenceCoverage(open: boolean) {
const [present, setPresent] = React.useState(false);
React.useEffect(() => {
if (open) setPresent(true);
}, [open]);
const markExitComplete = React.useCallback(() => setPresent(false), []);
return {
covered: open || present,
markExitComplete,
};
}
/** Keeps the covered channel inert and owns external dismissal while open. */
export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
const { covered, markExitComplete } = usePresenceCoverage(open);
React.useEffect(() => {
if (!open) return;
return subscribeToFocusedThreadCloseRequest(onClose);
}, [onClose, open]);
const markExitComplete = React.useCallback(() => setPresent(false), []);
return {
channelIsCovered: open || present,
channelIsCovered: covered,
markExitComplete,
};
}

View File

@@ -781,7 +781,7 @@ test("latest files commit opens its detail without a divider", async ({
await expect(page.getByTestId("project-commit-detail")).toBeVisible();
});
test("project workspace sheet enters at its settled width", async ({
test("project workspace sheet stays independent from an open thread", async ({
page,
}) => {
await enableProjectsFeature(page);
@@ -792,6 +792,7 @@ test("project workspace sheet enters at its settled width", async ({
await page.getByTestId("create-project-name").fill("sheet-motion-demo");
await page.getByTestId("create-project-submit").click();
await expect(page.getByTestId("project-channel-home")).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
const summaryColumn = page.getByTestId("project-home-summary-column");
const resizeHandle = summaryColumn.getByTestId(
@@ -822,6 +823,153 @@ test("project workspace sheet enters at its settled width", async ({
expect(
Math.abs(settledDrawerWidth - enteringDrawerWidth),
).toBeLessThanOrEqual(1);
await focusDrawer.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("project-home-workspace-sheet")).toHaveCount(0);
await expect(page.getByTestId("project-home-summary-column")).toBeVisible();
await waitForMockLiveSubscription(page, "sheet-motion-demo");
const threadRootContent = "Workspace drawer thread root";
await page.evaluate((content) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "sheet-motion-demo",
content,
});
}, threadRootContent);
const threadRoot = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: threadRootContent });
await expect(threadRoot).toBeVisible();
await threadRoot.hover();
await threadRoot.getByRole("button", { name: "Reply" }).click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("project-home-context-tasks").click();
await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible();
const workspaceDrawer = page.getByTestId("focus-thread-drawer");
await expect(workspaceDrawer).toHaveCount(1);
await expect(
workspaceDrawer.getByTestId("project-home-workspace-sheet"),
).toBeVisible();
await expect(page.getByTestId("message-thread-panel")).toHaveCount(1);
await expect(workspaceDrawer.getByTestId("message-thread-panel")).toHaveCount(
0,
);
const coveredThreadSurface = page.getByTestId("thread-surface");
await expect(coveredThreadSurface).toHaveAttribute("inert", "");
await expect(coveredThreadSurface).toHaveAttribute("aria-hidden", "true");
const coveredSnapshot = await page.locator("body").ariaSnapshot();
expect(coveredSnapshot).not.toContain(threadRootContent);
expect(coveredSnapshot).toContain("Tasks");
expect(coveredSnapshot).toContain("Close panel");
const workspaceClose = workspaceDrawer.getByTestId("auxiliary-panel-close");
await workspaceClose.focus();
await expect(workspaceClose).toBeFocused();
for (let index = 0; index < 8; index += 1) {
await page.keyboard.press("Tab");
expect(
await page.evaluate(
() =>
document.activeElement?.closest('[data-testid="thread-surface"]') !==
null,
),
).toBe(false);
}
await workspaceClose.click();
const exitingWorkspaceState = await page.evaluate(() => {
const workspaceSheet = document.querySelector(
'[data-testid="project-home-workspace-sheet"]',
);
const threadSurface = document.querySelector(
'[data-testid="thread-surface"]',
);
return {
threadAriaHidden: threadSurface?.getAttribute("aria-hidden"),
threadInert: threadSurface?.hasAttribute("inert") ?? false,
workspaceSheetMounted: workspaceSheet !== null,
};
});
expect(exitingWorkspaceState).toEqual({
threadAriaHidden: "true",
threadInert: true,
workspaceSheetMounted: true,
});
await expect(page.getByTestId("project-home-workspace-sheet")).toHaveCount(0);
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
const threadClose = coveredThreadSurface.getByTestId("auxiliary-panel-close");
await expect(threadClose).toBeFocused();
await expect(coveredThreadSurface).not.toHaveAttribute("inert", "");
await expect(coveredThreadSurface).not.toHaveAttribute("aria-hidden", "true");
await page.evaluate(() => {
document.documentElement.style.fontSize = "140%";
});
await page.getByTestId("project-home-context-tasks").click();
await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible();
const enlargedTextWorkspaceClose = page
.getByTestId("focus-thread-drawer")
.getByTestId("auxiliary-panel-close");
await expect
.poll(() =>
enlargedTextWorkspaceClose.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return {
leftInsideViewport: bounds.left >= 0,
rightInsideViewport: bounds.right <= window.innerWidth,
viewportWidth: window.innerWidth,
};
}),
)
.toEqual({
leftInsideViewport: true,
rightInsideViewport: true,
viewportWidth: 820,
});
await enlargedTextWorkspaceClose.click();
await expect(page.getByTestId("project-home-workspace-sheet")).toHaveCount(0);
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.evaluate(() => {
document.documentElement.style.removeProperty("font-size");
});
await page.setViewportSize({ height: 1080, width: 1920 });
const splitThreadPane = page
.locator(
'[data-testid="message-thread-panel"]:has([data-testid="right-auxiliary-pane-resize-handle"])',
)
.first();
await expect(splitThreadPane).toBeVisible();
const threadResizeHandle = splitThreadPane.getByTestId(
"right-auxiliary-pane-resize-handle",
);
const threadResizeHandleBox = await threadResizeHandle.boundingBox();
expect(threadResizeHandleBox).not.toBeNull();
await page.getByTestId("project-home-context-tasks").click();
await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible();
await waitForAnimations(page);
const workspaceCoversThreadDivider = await page.evaluate(
({ x, y }) => {
return Boolean(
document
.elementFromPoint(x, y)
?.closest('[data-testid="focus-thread-drawer"]'),
);
},
{
x:
(threadResizeHandleBox?.x ?? 0) +
(threadResizeHandleBox?.width ?? 0) / 2,
y: (threadResizeHandleBox?.y ?? 0) + 40,
},
);
expect(workspaceCoversThreadDivider).toBe(true);
await page.keyboard.press("Escape");
await expect(page.getByTestId("project-home-workspace-sheet")).toHaveCount(0);
await expect(splitThreadPane).toBeVisible();
await expect(threadClose).toBeFocused();
});
test("commit detail opens from the commits feed with a diff", async ({