Fix channel header shared blur layering (#1336)

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-06-29 09:42:31 -04:00
committed by GitHub
parent bc925780ee
commit 360d2e5440
15 changed files with 212 additions and 15 deletions

View File

@@ -22,6 +22,7 @@ export default defineConfig({
"**/smoke.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",

View File

@@ -39,6 +39,7 @@ type AgentSessionThreadPanelProps = {
onBackToProfile: () => void;
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
};
export function AgentSessionThreadPanel({
@@ -52,6 +53,7 @@ export function AgentSessionThreadPanel({
onBackToProfile,
onClose,
widthPx,
transparentChrome = false,
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
@@ -176,7 +178,9 @@ export function AgentSessionThreadPanel({
if (isSplitLayout) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader>{agentHeaderContent}</AuxiliaryPanelHeader>
<AuxiliaryPanelHeader transparent={transparentChrome}>
{agentHeaderContent}
</AuxiliaryPanelHeader>
{agentBody}
</div>
);

View File

@@ -17,6 +17,7 @@ type ChannelManagementAuxiliaryPanelProps = {
) => void;
threadPanelWidthPx: number;
useSplitAuxiliaryPane: boolean;
transparentChrome?: boolean;
};
export function ChannelManagementAuxiliaryPanel({
@@ -30,6 +31,7 @@ export function ChannelManagementAuxiliaryPanel({
onThreadPanelResizeStart,
threadPanelWidthPx,
useSplitAuxiliaryPane,
transparentChrome = false,
}: ChannelManagementAuxiliaryPanelProps) {
const panel = (
<ChannelManagementSheet
@@ -44,6 +46,7 @@ export function ChannelManagementAuxiliaryPanel({
}
}}
open={true}
transparentChrome={transparentChrome}
/>
);

View File

@@ -88,6 +88,7 @@ type ChannelManagementSheetProps = {
onDeleted?: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
transparentChrome?: boolean;
};
const DEFAULT_EPHEMERAL_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -100,6 +101,7 @@ export function ChannelManagementSheet({
onDeleted,
onOpenChange,
open,
transparentChrome = false,
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const isSplitLayout = layout === "split";
@@ -348,6 +350,7 @@ export function ChannelManagementSheet({
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
isSplitLayout={isSplitLayout}
transparentChrome={transparentChrome}
joinChannelMutation={joinChannelMutation}
leaveChannelMutation={leaveChannelMutation}
memberCount={memberCount}
@@ -628,6 +631,7 @@ type ChannelManagementPanelContentProps = {
isDeleteDialogOpen: boolean;
isOwner: boolean;
isSplitLayout: boolean;
transparentChrome?: boolean;
joinChannelMutation: ChannelMutation;
leaveChannelMutation: ChannelMutation;
memberCount: number;
@@ -659,6 +663,7 @@ function ChannelManagementPanelContent({
isDeleteDialogOpen,
isOwner,
isSplitLayout,
transparentChrome = false,
joinChannelMutation,
leaveChannelMutation,
memberCount,
@@ -680,7 +685,7 @@ function ChannelManagementPanelContent({
return (
<>
{isSplitLayout ? (
<AuxiliaryPanelHeader>
<AuxiliaryPanelHeader transparent={transparentChrome}>
<AuxiliaryPanelHeaderGroup>
{activeView === "canvas" ? (
<Button

View File

@@ -613,6 +613,13 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
Boolean(threadHeadMessage) ||
shouldShowThreadSkeleton ||
Boolean(activeChannel && selectedAgent) ||
Boolean(profilePanelPubkey));
const wrapAux = (panel: React.ReactNode, testId: string) =>
useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
@@ -628,7 +635,18 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
{!isSinglePanelView ? (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-30 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
channelChrome.headerHeight,
)}
data-testid="channel-shared-header-backdrop"
/>
) : null}
{!isSinglePanelView ? (
<section
aria-label="Channel messages and composer"
@@ -829,6 +847,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onThreadPanelResizeStart={onThreadPanelResizeStart}
threadPanelWidthPx={threadPanelWidthPx}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
(() => {
@@ -849,6 +868,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
@@ -900,6 +920,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
@@ -929,6 +950,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
profiles={profiles}
onBackToProfile={() => onOpenProfilePanel(selectedAgent.pubkey)}
onClose={onCloseAgentSession}
@@ -946,6 +968,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}

View File

@@ -725,6 +725,7 @@ export function ChannelScreen({
}}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
showHeaderContent={!isSinglePanelView}
transparentChrome={activeChannel?.channelType !== "forum"}
/>
);

View File

@@ -26,6 +26,7 @@ type ChannelScreenHeaderProps = {
isAddBotOpen?: boolean;
isJoining?: boolean;
showHeaderContent?: boolean;
transparentChrome?: boolean;
onAddBotOpenChange?: (open: boolean) => void;
onJoinChannel?: () => Promise<void>;
onManageChannel: () => void;
@@ -46,6 +47,7 @@ export function ChannelScreenHeader({
isJoining = false,
onAddBotOpenChange,
showHeaderContent = true,
transparentChrome = false,
onJoinChannel,
onManageChannel,
onToggleMembers,
@@ -120,6 +122,7 @@ export function ChannelScreenHeader({
/>
}
title={activeChannelTitle}
transparentChrome={transparentChrome}
visibility={activeChannel?.visibility}
/>
);

View File

@@ -2,7 +2,6 @@ import type * as React from "react";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { cn } from "@/shared/lib/cn";
import { PANEL_ENTER_MOTION_CLASS } from "@/shared/ui/OverlayPanelBackdrop";
type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
@@ -26,8 +25,7 @@ export function RightAuxiliaryPane({
return (
<aside
className={cn(
"group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-40 before:w-px before:bg-border/80 before:content-['']",
PANEL_ENTER_MOTION_CLASS,
"group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background 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-['']",
)}
data-testid={testId}
style={{
@@ -39,7 +37,7 @@ export function RightAuxiliaryPane({
>
<button
aria-label="Resize panel"
className="peer/right-pane-resize group/right-pane-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
className="peer/right-pane-resize group/right-pane-resize absolute inset-y-0 left-0 z-50 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="right-auxiliary-pane-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}

View File

@@ -32,6 +32,8 @@ type ChatHeaderProps = {
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
overlaysContent?: boolean;
statusBadge?: React.ReactNode;
/** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */
transparentChrome?: boolean;
};
const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground";
@@ -93,6 +95,7 @@ export function ChatHeader({
mode = "channel",
overlaysContent = false,
statusBadge,
transparentChrome = false,
}: ChatHeaderProps) {
const trimmedDescription = description?.trim() ?? "";
@@ -171,7 +174,10 @@ export function ChatHeader({
<div
ref={chromeWrapperRef}
className={cn(
"pointer-events-none relative z-30 overflow-hidden rounded-tl-xl bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
"pointer-events-none relative z-40 overflow-visible rounded-tl-xl",
transparentChrome
? "bg-transparent"
: "bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
channelChrome.negativeMargin,
)}
>

View File

@@ -88,6 +88,7 @@ type MessageThreadPanelProps = {
threadHeadVideoReviewContext?: VideoReviewContext;
toolbarExtraActions?: React.ReactNode;
widthPx: number;
transparentChrome?: boolean;
isFollowingThread?: boolean;
isMessageUnreadById?: (messageId: string) => boolean;
onFollowThread?: () => void;
@@ -103,6 +104,7 @@ type MessageThreadPanelSkeletonProps = {
layout?: "standalone" | "split";
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
};
function canManageMessage(
@@ -225,6 +227,7 @@ export function MessageThreadPanelSkeleton({
layout = "standalone",
onClose,
widthPx,
transparentChrome = false,
}: MessageThreadPanelSkeletonProps) {
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
@@ -296,7 +299,9 @@ export function MessageThreadPanelSkeleton({
if (isSplitLayout) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
<AuxiliaryPanelHeader transparent={transparentChrome}>
{threadHeaderContent}
</AuxiliaryPanelHeader>
{threadBody}
<ThreadComposerSkeleton />
</div>
@@ -379,6 +384,7 @@ export function MessageThreadPanel({
threadTypingPubkeys,
toolbarExtraActions,
widthPx,
transparentChrome = false,
}: MessageThreadPanelProps) {
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadContentRef = React.useRef<HTMLDivElement>(null);
@@ -943,7 +949,9 @@ export function MessageThreadPanel({
if (isSplitLayout) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
<AuxiliaryPanelHeader transparent={transparentChrome}>
{threadHeaderContent}
</AuxiliaryPanelHeader>
{threadScrollRegion}
{threadFooter}
</div>

View File

@@ -95,7 +95,6 @@ import type {
} from "@/shared/api/types";
import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame";
import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent";
export type { ProfilePanelTab, ProfilePanelView };
export function UserProfilePanel({
@@ -116,6 +115,7 @@ export function UserProfilePanel({
tab: controlledTab,
view: controlledView,
widthPx,
transparentChrome = false,
}: UserProfilePanelProps) {
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
@@ -993,6 +993,7 @@ export function UserProfilePanel({
profileBody={profileBody}
splitPaneClamp={splitPaneClamp}
widthPx={widthPx}
transparentChrome={transparentChrome}
/>
);
}

View File

@@ -27,6 +27,7 @@ type UserProfilePanelFrameProps = {
profileBody: React.ReactNode;
splitPaneClamp: boolean;
widthPx: number;
transparentChrome?: boolean;
};
export function UserProfilePanelFrame({
@@ -46,12 +47,13 @@ export function UserProfilePanelFrame({
profileBody,
splitPaneClamp,
widthPx,
transparentChrome = false,
}: UserProfilePanelFrameProps) {
if (isSplitLayout) {
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader>
<AuxiliaryPanelHeader transparent={transparentChrome}>
{headerLeftContent}
{headerActions}
</AuxiliaryPanelHeader>

View File

@@ -110,6 +110,7 @@ export type UserProfilePanelProps = {
tab?: ProfilePanelTab;
view?: ProfilePanelView;
widthPx: number;
transparentChrome?: boolean;
};
export function deriveProfileChannels(

View File

@@ -3,7 +3,10 @@ import type * as React from "react";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
type AuxiliaryPanelHeaderProps = React.ComponentProps<"div">;
type AuxiliaryPanelHeaderProps = React.ComponentProps<"div"> & {
/** Render header content without its own backdrop for a shared parent chrome. */
transparent?: boolean;
};
type AuxiliaryPanelHeaderGroupProps = React.ComponentProps<"div">;
type AuxiliaryPanelTitleProps = React.ComponentProps<"h2">;
@@ -11,19 +14,23 @@ type AuxiliaryPanelTitleProps = React.ComponentProps<"h2">;
export function AuxiliaryPanelHeader({
className,
children,
transparent = false,
...props
}: AuxiliaryPanelHeaderProps) {
return (
<div
className={cn(
"pointer-events-none relative z-30 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
"pointer-events-none relative z-40 overflow-visible",
transparent
? "bg-transparent"
: "bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
channelChrome.negativeMargin,
className,
)}
{...props}
>
<div
className="pointer-events-auto relative z-30 shrink-0 cursor-default select-none px-5 py-2"
className="pointer-events-auto relative z-40 shrink-0 cursor-default select-none px-5 py-2"
data-tauri-drag-region
>
<div className="flex h-9 min-w-0 items-center gap-2.5">{children}</div>

View File

@@ -0,0 +1,134 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
type MockMessageWindow = Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
}) => { id: string } | undefined;
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
};
const CHANNEL_NAME = "engineering";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(async () => {
return page.evaluate((name) => {
return (
(
window as MockMessageWindow
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: name }) ??
false
);
}, channelName);
})
.toBe(true);
}
test.describe("channel shared header backdrop", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("spans channel and split auxiliary columns with one backdrop", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId(`channel-${CHANNEL_NAME}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME);
await waitForMockLiveSubscription(page, CHANNEL_NAME);
const rootId = await page.evaluate(
({ channelName, pubkey }) =>
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: "Root message for shared header backdrop coverage.",
pubkey,
})?.id ?? null,
{ channelName: CHANNEL_NAME, pubkey: MOCK_IDENTITY_PUBKEY },
);
expect(rootId).not.toBeNull();
await page.evaluate(
({ channelName, parentEventId, pubkey }) => {
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: "Reply that opens a split thread panel.",
parentEventId,
pubkey,
});
},
{
channelName: CHANNEL_NAME,
parentEventId: rootId,
pubkey: ALICE_PUBKEY,
},
);
const replyButton = page.locator('[data-testid^="reply-message-"]').first();
await expect(replyButton).toBeVisible();
await replyButton.click({ force: true });
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
const sharedBackdrop = page.getByTestId("channel-shared-header-backdrop");
await expect(sharedBackdrop).toHaveCount(1);
const chatHeader = page.getByTestId("chat-header");
const auxiliaryResizeHandle = page.getByTestId(
"right-auxiliary-pane-resize-handle",
);
const [
hostBox,
backdropBox,
backdropFilter,
backdropZIndex,
headerZIndex,
resizeHandleZIndex,
auxiliaryPaneAnimationName,
] = await Promise.all([
page.getByTestId("channel-drop-zone").locator("..").boundingBox(),
sharedBackdrop.boundingBox(),
sharedBackdrop.evaluate(
(element) => getComputedStyle(element).backdropFilter,
),
sharedBackdrop.evaluate((element) =>
Number(getComputedStyle(element).zIndex),
),
chatHeader.evaluate((element) =>
Number(getComputedStyle(element.parentElement ?? element).zIndex),
),
auxiliaryResizeHandle.evaluate((element) =>
Number(getComputedStyle(element).zIndex),
),
page
.getByTestId("message-thread-panel")
.evaluate((element) => getComputedStyle(element).animationName),
]);
expect(hostBox).not.toBeNull();
expect(backdropBox).not.toBeNull();
expect(Math.round(backdropBox?.x ?? 0)).toBe(Math.round(hostBox?.x ?? 0));
expect(Math.round(backdropBox?.width ?? 0)).toBe(
Math.round(hostBox?.width ?? 0),
);
expect(backdropFilter).not.toBe("none");
expect(headerZIndex).toBeGreaterThan(backdropZIndex);
expect(resizeHandleZIndex).toBeGreaterThan(backdropZIndex);
expect(auxiliaryPaneAnimationName).toBe("none");
await waitForAnimations(page);
});
});