fix(projects): overlay workspace sheets above threads

Give project workspace sheets explicit auxiliary priority so Tasks, Reviews, Commits, Files, and People remain accessible without discarding an open conversation thread.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Co-authored-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-23 21:32:30 -04:00
committed by Wrench
parent d5c6df5b81
commit 593de88cd2
6 changed files with 49 additions and 31 deletions

View File

@@ -84,6 +84,7 @@ export const ChannelPane = React.memo(function ChannelPane({
header,
idleAuxiliaryPanel = null,
idleAuxiliaryHeaderActions,
idleAuxiliaryOverridesThread = false,
idleAuxiliaryTitle = "",
hasOlderMessages,
historyExhausted,
@@ -487,11 +488,15 @@ export const ChannelPane = React.memo(function ChannelPane({
hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton,
useSplitAuxiliaryPane,
});
const priorityIdleAuxiliary =
idleAuxiliaryOverridesThread && useFocusIdleDrawer;
const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
useFocusThreadDrawer || useFocusIdleDrawer,
useFocusThreadDrawer
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
priorityIdleAuxiliary
? (onCloseIdleAuxiliaryPanel ?? onCloseThread)
: useFocusThreadDrawer
? onCloseThread
: (onCloseIdleAuxiliaryPanel ?? onCloseThread),
);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
useThreadViewModeSwitch({
@@ -551,6 +556,25 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
wrapAux(panel, "idle-auxiliary-panel")
);
const idleAuxiliarySurface =
idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel
? wrapIdlePanel(
<IdleAuxiliaryPanel
canResetWidth={canResetThreadPanelWidth}
headerControls={idleAuxiliaryHeaderActions}
isFocusDrawer={useFocusIdleDrawer}
isSinglePanelView={isSinglePanelView}
onClose={onCloseIdleAuxiliaryPanel}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
title={idleAuxiliaryTitle}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
widthPx={threadPanelWidthPx}
>
{idleAuxiliaryPanel}
</IdleAuxiliaryPanel>,
)
: null;
const threadHeaderLeading = useSplitAuxiliaryPane ? (
<ThreadViewModeToggle onChange={changeThreadViewMode} />
) : undefined;
@@ -799,14 +823,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</section>
) : null}
{/*
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
* animation — without it the drawer's own existence condition
* (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes
* false on the same frame as the close, and there is nothing left to
* animate. It can hold the real thread through the exit rather than a
* frozen snapshot because the panel is fully prop-driven.
*/}
{/* Keep focus drawers mounted until their exit animation finishes. */}
<AnimatePresence onExitComplete={markExitComplete}>
{channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
@@ -824,6 +841,8 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : priorityIdleAuxiliary && idleAuxiliarySurface ? (
idleAuxiliarySurface
) : threadHeadMessage ? (
(() => {
const panel = (
@@ -975,24 +994,9 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "user-profile-panel");
})()
) : idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel ? (
wrapIdlePanel(
<IdleAuxiliaryPanel
canResetWidth={canResetThreadPanelWidth}
headerControls={idleAuxiliaryHeaderActions}
isFocusDrawer={useFocusIdleDrawer}
isSinglePanelView={isSinglePanelView}
onClose={onCloseIdleAuxiliaryPanel}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
title={idleAuxiliaryTitle}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
widthPx={threadPanelWidthPx}
>
{idleAuxiliaryPanel}
</IdleAuxiliaryPanel>,
)
) : null}
) : (
idleAuxiliarySurface
)}
</AnimatePresence>
</div>
);

View File

@@ -49,11 +49,13 @@ export type ChannelPaneProps = {
header?: React.ReactNode;
/**
* Idle-state body for the right auxiliary pane (project extras, etc.).
* Shown only when no thread, profile, agent session, or channel-management
* panel is open — the same slot as those panels.
* Uses the same slot as thread, profile, agent-session, and management panels.
* By default it yields to those surfaces; callers may opt into thread override.
*/
idleAuxiliaryPanel?: React.ReactNode;
idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls;
/** Show the idle auxiliary surface ahead of an already-open thread. */
idleAuxiliaryOverridesThread?: boolean;
idleAuxiliaryTitle?: string;
hasOlderMessages?: boolean;
/** True when the loaded window provably starts at the channel's beginning. */

View File

@@ -91,6 +91,7 @@ export function ChannelScreen({
headerEndActions,
idleAuxiliaryPanel,
idleAuxiliaryHeaderActions,
idleAuxiliaryOverridesThread,
idleAuxiliaryTitle,
onAddFiles,
onCloseIdleAuxiliaryPanel,
@@ -855,6 +856,7 @@ export function ChannelScreen({
header={channelHeader}
idleAuxiliaryPanel={idleAuxiliaryPanel}
idleAuxiliaryHeaderActions={idleAuxiliaryHeaderActions}
idleAuxiliaryOverridesThread={idleAuxiliaryOverridesThread}
idleAuxiliaryTitle={idleAuxiliaryTitle}
hasOlderMessages={hasOlderMessages}
historyExhausted={historyExhausted}

View File

@@ -21,6 +21,7 @@ export type ChannelScreenProps = {
currentProfile?: Profile;
idleAuxiliaryPanel?: ReactNode;
idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls;
idleAuxiliaryOverridesThread?: boolean;
idleAuxiliaryTitle?: string;
headerEndActions?: ReactNode;
onAddFiles?: () => void;

View File

@@ -352,6 +352,7 @@ export function ProjectChannelHome({
backLabel: workspaceDetail?.backLabel,
onBack: workspaceDetail?.onBack,
}}
idleAuxiliaryOverridesThread={workspaceSheetOpen}
idleAuxiliaryTitle={
workspaceSheetTab
? projectHomeWorkspaceSheetTitle(workspaceSheetTab)

View File

@@ -791,6 +791,14 @@ test("commit detail opens from the commits feed with a diff", async ({
.first();
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
await projectEntry.click();
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.first();
await expect(rootMessage).toBeVisible();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
await expect(page.getByTestId("focus-thread-drawer")).toBeVisible();
await page.getByTestId("project-home-context-tasks").click();
await expect(page.getByTestId("project-home-workspace-sheet")).toBeVisible();
const workspaceSheet = page.getByTestId("project-home-workspace-sheet");