refactor(desktop): centralize auxiliary panel shell (#1343)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-29 16:19:48 -06:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent db1b617ab1
commit e6738c5015
26 changed files with 1205 additions and 625 deletions
@@ -1,4 +1,4 @@
import { ArrowLeft, CircleDot, Octagon, X } from "lucide-react";
import { Octagon, Settings } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -8,24 +8,22 @@ import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
auxiliaryPanelContentPaddingClass,
} from "@/shared/layout/AuxiliaryPanelHeader";
import { Badge } from "@/shared/ui/badge";
} from "@/shared/layout/AuxiliaryPanel";
import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
OverlayPanelBackdrop,
PANEL_ENTER_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
type AgentSessionThreadPanelProps = {
@@ -57,8 +55,7 @@ export function AgentSessionThreadPanel({
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
const canStopCurrentTurn = isWorking && canInterruptTurn;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
@@ -83,146 +80,116 @@ export function AgentSessionThreadPanel({
}
const agentHeaderActions = (
<div className="ml-auto flex shrink-0 items-center gap-2">
<AuxiliaryPanelHeaderActions>
{isLive && isWorking ? (
<Badge className="shrink-0 gap-1 px-2 py-0 text-2xs" variant="default">
<CircleDot className="h-2.5 w-2.5" />
Live
</Badge>
) : null}
{isLive && isWorking ? (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Stop current agent turn"
className="h-6 px-2 text-2xs"
aria-label="Open activity settings"
className="relative"
data-testid="agent-session-settings-menu-trigger"
size="icon"
title="Activity settings"
type="button"
variant="ghost"
>
<Settings />
{canStopCurrentTurn ? (
<span
aria-hidden="true"
className="absolute right-1 bottom-1 h-2 w-2 rounded-full bg-primary ring-2 ring-background"
data-testid="agent-session-settings-live-badge"
/>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-56"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
className="items-start gap-3"
data-testid="agent-session-stop-turn"
disabled={!canInterruptTurn}
onClick={() => {
disabled={!canStopCurrentTurn}
onSelect={() => {
void handleInterruptTurn();
}}
size="sm"
type="button"
variant="outline"
title={
canStopCurrentTurn
? "Interrupt the current ACP turn without stopping the agent process."
: "Only locally managed agents can be interrupted from this workspace."
}
>
<Octagon className="h-4 w-4" />
Stop
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{canInterruptTurn
? "Interrupt the current ACP turn without stopping the agent process."
: "This agent cannot be interrupted from this workspace."}
</TooltipContent>
</Tooltip>
<Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
Stop current turn
</span>
{!canStopCurrentTurn ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
Only available for locally managed agents.
</span>
) : null}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
aria-label="Close activity panel"
data-testid="agent-session-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</div>
</AuxiliaryPanelHeaderActions>
);
const agentHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup>
<Button
aria-label="Back from activity"
className="shrink-0"
data-testid="agent-session-back"
onClick={onBackToProfile}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft />
</Button>
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back from activity"
backButtonTestId="agent-session-back"
onBack={onBackToProfile}
>
<AuxiliaryPanelTitle>Activity</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
{agentHeaderActions}
</>
);
const agentBody = (
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && (isFloatingOverlay ? "pt-4" : "pt-[3.25rem]"),
)}
>
<ManagedAgentSessionPanel
agent={agent}
channelId={channel?.id ?? null}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={
channel
? `Mention ${agent.name} in the channel to see its work here.`
: `Mention ${agent.name} in any channel to see its work here.`
}
profiles={profiles}
showHeader={false}
showRaw={false}
/>
</div>
);
if (isSplitLayout) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader transparent={transparentChrome}>
{agentHeaderContent}
</AuxiliaryPanelHeader>
{agentBody}
</div>
);
}
return (
<>
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="agent-session-thread-panel"
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[3.25rem] bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
/>
) : null}
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[3.25rem] min-h-[3.25rem] shrink-0 gap-2.5 bg-background/80 px-4 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pl-6 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
: "relative z-50 min-h-[3.25rem] shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55",
)}
data-tauri-drag-region
<AuxiliaryPanel
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="agent-session-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
header={
<AuxiliaryPanelHeader
backdrop={layout !== "split" && !isOverlay}
backdropSurface="soft"
inset={layout !== "split" ? "wide" : "default"}
>
{agentHeaderContent}
</div>
{agentBody}
</aside>
</>
</AuxiliaryPanelHeader>
}
>
<AuxiliaryPanelBody
ref={scrollRef}
onScroll={onScroll}
className="overflow-y-auto px-3 pb-4"
panelPadding
>
<ManagedAgentSessionPanel
agent={agent}
channelId={channel?.id ?? null}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={
channel
? `Mention ${agent.name} in the channel to see its work here.`
: `Mention ${agent.name} in any channel to see its work here.`
}
profiles={profiles}
showHeader={false}
showRaw={false}
/>
</AuxiliaryPanelBody>
</AuxiliaryPanel>
);
}
@@ -1,7 +1,6 @@
import {
Archive,
BookOpenText,
ChevronLeft,
Copy,
DoorClosed,
DoorOpen,
@@ -14,7 +13,6 @@ import {
Radio,
Type,
Users,
X,
Zap,
} from "lucide-react";
import * as React from "react";
@@ -53,11 +51,14 @@ import {
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import {
AuxiliaryPanelBody,
AuxiliaryPanelContext,
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
auxiliaryPanelContentPaddingClass,
} from "@/shared/layout/AuxiliaryPanelHeader";
type AuxiliaryPanelMode,
getAuxiliaryPanelMode,
} from "@/shared/layout/AuxiliaryPanel";
import { useScrollBoundaryLock } from "@/shared/hooks/useScrollBoundaryLock";
import {
OverlayPanelBackdrop,
@@ -105,6 +106,10 @@ export function ChannelManagementSheet({
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const isSplitLayout = layout === "split";
const auxiliaryPanelMode = getAuxiliaryPanelMode(
isSplitLayout,
!isSplitLayout,
);
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
@@ -322,7 +327,7 @@ export function ChannelManagementSheet({
"h-full w-full cursor-default overflow-hidden border-l-0 p-0",
animateSplitEnter && PANEL_ENTER_MOTION_CLASS,
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
? "bg-background/85 backdrop-blur-xl supports-backdrop-filter:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
@@ -349,7 +354,7 @@ export function ChannelManagementSheet({
isDark={isDark}
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
isSplitLayout={isSplitLayout}
mode={auxiliaryPanelMode}
transparentChrome={transparentChrome}
joinChannelMutation={joinChannelMutation}
leaveChannelMutation={leaveChannelMutation}
@@ -371,7 +376,7 @@ export function ChannelManagementSheet({
PANEL_ENTER_MOTION_CLASS,
"w-[380px] cursor-default overflow-hidden p-0 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=closed]:duration-200",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
? "bg-background/85 backdrop-blur-xl supports-backdrop-filter:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
@@ -395,7 +400,8 @@ export function ChannelManagementSheet({
isDark={isDark}
isDeleteDialogOpen={isDeleteDialogOpen}
isOwner={isOwner}
isSplitLayout={isSplitLayout}
mode={auxiliaryPanelMode}
transparentChrome={transparentChrome}
joinChannelMutation={joinChannelMutation}
leaveChannelMutation={leaveChannelMutation}
memberCount={memberCount}
@@ -630,7 +636,7 @@ type ChannelManagementPanelContentProps = {
isDark: boolean;
isDeleteDialogOpen: boolean;
isOwner: boolean;
isSplitLayout: boolean;
mode: AuxiliaryPanelMode;
transparentChrome?: boolean;
joinChannelMutation: ChannelMutation;
leaveChannelMutation: ChannelMutation;
@@ -662,7 +668,7 @@ function ChannelManagementPanelContent({
isDark,
isDeleteDialogOpen,
isOwner,
isSplitLayout,
mode,
transparentChrome = false,
joinChannelMutation,
leaveChannelMutation,
@@ -681,107 +687,52 @@ function ChannelManagementPanelContent({
activeView === "summary" &&
canManageChannel &&
resolvedChannel.channelType !== "dm";
return (
<>
{isSplitLayout ? (
<AuxiliaryPanelHeader transparent={transparentChrome}>
<AuxiliaryPanelHeaderGroup>
{activeView === "canvas" ? (
<Button
aria-label="Back to channel"
className="shrink-0"
data-testid="channel-management-back"
onClick={() => setActiveView("summary")}
size="icon"
type="button"
variant="outline"
>
<ChevronLeft />
</Button>
) : null}
<DialogPrimitive.Title asChild>
<AuxiliaryPanelTitle>
{activeView === "canvas" ? "Canvas" : "Channel"}
</AuxiliaryPanelTitle>
</DialogPrimitive.Title>
</AuxiliaryPanelHeaderGroup>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
aria-label="Close channel management"
className="relative z-[60]"
data-testid="channel-management-close"
onClick={() => onOpenChange(false)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange(false);
}}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</div>
<DialogPrimitive.Description className="sr-only">
Channel settings
</DialogPrimitive.Description>
</AuxiliaryPanelHeader>
) : (
<div
className={cn(
"relative z-10 flex min-h-11 flex-row items-center gap-3 space-y-0 border-b border-border/35 px-3 py-1.5 text-left shadow-none",
isDark
? "bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
: "bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/70",
)}
<AuxiliaryPanelContext.Provider
value={{
isFloatingOverlay: mode === "panel",
isOverlay: mode !== "docked",
isSinglePanelView: mode === "single-panel",
isSplitLayout: mode === "docked",
layout: mode === "docked" ? "split" : "standalone",
mode,
onClose: () => onOpenChange(false),
transparentChrome,
widthPx: 380,
}}
>
<AuxiliaryPanelHeader
bordered={mode === "panel"}
density={mode === "panel" ? "compact" : "comfortable"}
mode={mode}
transparent={transparentChrome}
>
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to channel"
backButtonTestId="channel-management-back"
mode={mode}
onBack={
activeView === "canvas" ? () => setActiveView("summary") : undefined
}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{activeView === "canvas" ? (
<Button
aria-label="Back to channel"
data-testid="channel-management-back"
onClick={() => setActiveView("summary")}
size="icon"
type="button"
variant="ghost"
>
<ChevronLeft />
</Button>
) : null}
<DialogPrimitive.Title className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight">
<DialogPrimitive.Title asChild>
<AuxiliaryPanelTitle>
{activeView === "canvas" ? "Canvas" : "Channel"}
</DialogPrimitive.Title>
</div>
<Button
aria-label="Close channel management"
className="relative z-[60]"
data-testid="channel-management-close"
onClick={() => onOpenChange(false)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange(false);
}}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
<DialogPrimitive.Description className="sr-only">
Channel settings
</DialogPrimitive.Description>
</div>
)}
</AuxiliaryPanelTitle>
</DialogPrimitive.Title>
</AuxiliaryPanelHeaderGroup>
<DialogPrimitive.Description className="sr-only">
Channel settings
</DialogPrimitive.Description>
</AuxiliaryPanelHeader>
<div
<AuxiliaryPanelBody
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain bg-background px-4 [overflow-anchor:none]",
"overflow-y-auto overflow-x-hidden overscroll-contain bg-background px-4 [overflow-anchor:none]",
showModerationActions ? "pb-20" : "pb-8",
isSplitLayout ? auxiliaryPanelContentPaddingClass : "pt-4",
)}
mode={mode}
panelPadding
ref={scrollRef}
>
{activeView === "summary" ? (
@@ -970,7 +921,7 @@ function ChannelManagementPanelContent({
/>
</div>
)}
</div>
</AuxiliaryPanelBody>
{showModerationActions ? (
<ChannelManagementModerationActions
@@ -987,6 +938,6 @@ function ChannelManagementPanelContent({
unarchiveChannelMutation={unarchiveChannelMutation}
/>
) : null}
</>
</AuxiliaryPanelContext.Provider>
);
}
@@ -60,10 +60,8 @@ import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import {
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
useThreadPanelWidth,
} from "@/shared/hooks/useThreadPanelWidth";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
mergeAgentNamesIntoProfiles,
@@ -725,7 +723,7 @@ export function ChannelScreen({
);
const isNarrowPanelViewport =
channelContentWidthPx > 0 &&
channelContentWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
channelContentWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const isSinglePanelView =
isNarrowPanelViewport &&
activeChannel?.channelType !== "forum" &&
@@ -1,6 +1,6 @@
import type * as React from "react";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel";
import { cn } from "@/shared/lib/cn";
type RightAuxiliaryPaneProps = {
@@ -30,7 +30,7 @@ export function RightAuxiliaryPane({
data-testid={testId}
style={{
maxWidth: constrainToAvailableSpace
? `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`
? `calc(100% - ${AUXILIARY_PANEL_MIN_WIDTH_PX}px)`
: undefined,
width: widthPx,
}}
+3 -5
View File
@@ -52,10 +52,8 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import {
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
useThreadPanelWidth,
} from "@/shared/hooks/useThreadPanelWidth";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { Button } from "@/shared/ui/button";
@@ -184,7 +182,7 @@ export function HomeView({
const isSinglePanelChannelManagementView =
isChannelManagementOpen &&
homeInboxWidthPx > 0 &&
homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
@@ -1,5 +1,5 @@
import * as React from "react";
import { ArrowDown, ArrowLeft, X } from "lucide-react";
import { ArrowDown } from "lucide-react";
import {
buildThreadSummaryFromVisibleEntries,
@@ -12,21 +12,15 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
auxiliaryPanelContentPaddingClass,
} from "@/shared/layout/AuxiliaryPanelHeader";
} from "@/shared/layout/AuxiliaryPanel";
import { Button } from "@/shared/ui/button";
import {
OverlayPanelBackdrop,
PANEL_ENTER_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { Skeleton } from "@/shared/ui/skeleton";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageComposer } from "./MessageComposer";
@@ -232,47 +226,22 @@ export function MessageThreadPanelSkeleton({
transparentChrome = false,
}: MessageThreadPanelSkeletonProps) {
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const threadHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup>
{isSinglePanelView ? (
<Button
aria-label="Back to conversation"
className="shrink-0"
onClick={onClose}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft />
</Button>
) : null}
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
onBack={isSinglePanelView ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
<Button
aria-label="Close thread"
className="ml-auto"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</>
);
const threadBody = (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
)}
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
data-testid="message-thread-loading"
>
<div
@@ -295,53 +264,25 @@ export function MessageThreadPanelSkeleton({
<Skeleton className="h-4 w-28 rounded-full" />
</div>
</div>
</div>
</AuxiliaryPanelBody>
);
if (isSplitLayout) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader transparent={transparentChrome}>
{threadHeaderContent}
</AuxiliaryPanelHeader>
{threadBody}
<ThreadComposerSkeleton />
</div>
);
}
return (
<>
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="message-thread-panel"
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[3.25rem] min-h-[3.25rem] shrink-0 gap-2.5 bg-background/80 px-4 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
: "relative z-50 min-h-[3.25rem] shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55",
)}
data-tauri-drag-region
>
{threadHeaderContent}
</div>
{threadBody}
<ThreadComposerSkeleton />
</aside>
</>
<AuxiliaryPanel
className="relative"
footer={<ThreadComposerSkeleton />}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadBody}
</AuxiliaryPanel>
);
}
@@ -400,8 +341,6 @@ export function MessageThreadPanel({
string | null
>(null);
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
const threadHeadId = threadHead?.id ?? null;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
useComposerHeightPadding(
@@ -620,12 +559,8 @@ export function MessageThreadPanel({
}
const threadScrollRegion = (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[3.25rem]",
)}
<AuxiliaryPanelBody
className="overflow-y-auto overflow-x-hidden overscroll-contain pb-24"
data-testid="message-thread-body"
onScroll={onScroll}
ref={threadBodyRef}
@@ -847,7 +782,7 @@ export function MessageThreadPanel({
null}
</div>
</div>
</div>
</AuxiliaryPanelBody>
);
const threadFooter = (
@@ -924,79 +859,31 @@ export function MessageThreadPanel({
const threadHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup>
{isSinglePanelView ? (
<Button
aria-label="Back to conversation"
className="shrink-0"
data-testid="message-thread-back"
onClick={onClose}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft />
</Button>
) : null}
<AuxiliaryPanelHeaderGroup
backButtonAriaLabel="Back to conversation"
backButtonTestId="message-thread-back"
onBack={isSinglePanelView ? onClose : undefined}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
<Button
aria-label="Close thread"
className="ml-auto"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</>
);
if (isSplitLayout) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader transparent={transparentChrome}>
{threadHeaderContent}
</AuxiliaryPanelHeader>
{threadScrollRegion}
{threadFooter}
</div>
);
}
return (
<>
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="message-thread-panel"
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[3.25rem] min-h-[3.25rem] shrink-0 gap-2.5 bg-background/80 px-4 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
: "relative z-50 min-h-[3.25rem] shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55",
)}
data-tauri-drag-region
>
{threadHeaderContent}
</div>
{threadScrollRegion}
{threadFooter}
</aside>
</>
<AuxiliaryPanel
className="relative"
footer={threadFooter}
header={
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
}
isSinglePanelView={isSinglePanelView}
layout={layout}
onClose={onClose}
testId="message-thread-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
>
{threadScrollRegion}
</AuxiliaryPanel>
);
}
@@ -83,7 +83,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { auxiliaryPanelContentPaddingClass } from "@/shared/layout/AuxiliaryPanelHeader";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import { cn } from "@/shared/lib/cn";
import type {
AgentPersona,
@@ -118,7 +118,6 @@ export function UserProfilePanel({
transparentChrome = false,
}: UserProfilePanelProps) {
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
@@ -804,21 +803,18 @@ export function UserProfilePanel({
logCopyValue: isDiagnosticsLikeView ? managedAgentLogContent : null,
logSubtitle: logHeaderSubtitle,
onBack: () => setView("summary"),
onClose,
view,
viewerIsOwner,
},
);
const profileBody = (
<div
<AuxiliaryPanelBody
className={cn(
"min-h-0 flex-1 px-4 pb-6",
"px-4 pb-6",
isDiagnosticsLikeView
? "flex flex-col overflow-hidden"
: "overflow-y-auto",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-13",
)}
>
{view === "summary" ? (
@@ -927,7 +923,7 @@ export function UserProfilePanel({
managedAgent={managedAgent}
/>
) : null}
</div>
</AuxiliaryPanelBody>
);
const editAgentDialog =
canEditAgent && managedAgent ? (
@@ -982,7 +978,6 @@ export function UserProfilePanel({
editAgentDialog={editAgentDialog}
headerActions={headerActions}
headerLeftContent={headerLeftContent}
isFloatingOverlay={isFloatingOverlay}
isOverlay={isOverlay}
isSinglePanelView={isSinglePanelView}
isSplitLayout={isSplitLayout}
@@ -1,14 +1,7 @@
import type * as React from "react";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { AuxiliaryPanelHeader } from "@/shared/layout/AuxiliaryPanelHeader";
import { cn } from "@/shared/lib/cn";
import {
OverlayPanelBackdrop,
PANEL_ENTER_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelHeader } from "@/shared/layout/AuxiliaryPanel";
type UserProfilePanelFrameProps = {
addAgentToChannelDialog: React.ReactNode;
@@ -16,7 +9,6 @@ type UserProfilePanelFrameProps = {
editAgentDialog: React.ReactNode;
headerActions: React.ReactNode;
headerLeftContent: React.ReactNode;
isFloatingOverlay: boolean;
isOverlay: boolean;
isSinglePanelView: boolean;
isSplitLayout: boolean;
@@ -36,7 +28,6 @@ export function UserProfilePanelFrame({
editAgentDialog,
headerActions,
headerLeftContent,
isFloatingOverlay,
isOverlay,
isSinglePanelView,
isSplitLayout,
@@ -49,86 +40,40 @@ export function UserProfilePanelFrame({
widthPx,
transparentChrome = false,
}: UserProfilePanelFrameProps) {
if (isSplitLayout) {
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader transparent={transparentChrome}>
{headerLeftContent}
{headerActions}
</AuxiliaryPanelHeader>
{profileBody}
</div>
{editAgentDialog}
{addAgentToChannelDialog}
{personaDialogs}
</>
);
}
return (
<>
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="user-profile-panel"
style={{
width: isSinglePanelView
? "100%"
: splitPaneClamp
? `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
: `${widthPx}px`,
}}
>
{!isOverlay && !isSinglePanelView && onResizeStart && (
<button
aria-label="Resize profile panel"
className="peer/profile-resize group/profile-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="user-profile-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/profile-resize:bg-border/80 group-focus-visible/profile-resize:bg-border/80" />
</button>
)}
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-13 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"
/>
) : null}
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-13 min-h-13 shrink-0 gap-2.5 bg-transparent px-4 py-2 sm:pl-6 sm:pr-3`
: isOverlay
? "relative z-50 min-h-13 shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55"
: "absolute inset-x-0 top-0 z-50 min-h-13 gap-3 bg-transparent px-3 py-2 after:absolute after:bottom-0 after:-left-px after:top-0 after:w-px after:bg-border/45 after:transition-colors peer-hover/profile-resize:after:bg-border/80 peer-focus-visible/profile-resize:after:bg-border/80",
)}
data-tauri-drag-region
<AuxiliaryPanel
canResetWidth={canResetWidth}
isSinglePanelView={isSinglePanelView}
layout={isSplitLayout ? "split" : "standalone"}
onClose={onClose}
onResetWidth={onResetWidth}
onResizeStart={onResizeStart}
resizeHandleAriaLabel="Resize profile panel"
resizeHandleTestId="user-profile-resize-handle"
siblings={
<>
{editAgentDialog}
{addAgentToChannelDialog}
{personaDialogs}
</>
}
splitPaneClamp={splitPaneClamp}
testId="user-profile-panel"
transparentChrome={transparentChrome}
widthPx={widthPx}
header={
<AuxiliaryPanelHeader
backdrop={!isSplitLayout && !isOverlay}
inset={!isSplitLayout ? "wide" : "default"}
resizeBorder={!isSinglePanelView && !isOverlay && !isSplitLayout}
surface={isSinglePanelView ? "transparent" : "default"}
>
{headerLeftContent}
{headerActions}
</div>
{profileBody}
</aside>
{editAgentDialog}
{addAgentToChannelDialog}
{personaDialogs}
</>
</AuxiliaryPanelHeader>
}
>
{profileBody}
</AuxiliaryPanel>
);
}
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { ArrowLeft, X } from "lucide-react";
import { CopyButton } from "@/features/agents/ui/CopyButton";
import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection";
@@ -8,10 +7,10 @@ import {
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import {
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
} from "@/shared/layout/AuxiliaryPanelHeader";
import { Button } from "@/shared/ui/button";
AuxiliaryPanelHeaderTitleBlock,
} from "@/shared/layout/AuxiliaryPanel";
export function getUserProfilePanelHeaderContent({
agentSettingsMenu,
@@ -19,7 +18,6 @@ export function getUserProfilePanelHeaderContent({
logCopyValue,
logSubtitle,
onBack,
onClose,
view,
viewerIsOwner,
}: {
@@ -28,7 +26,6 @@ export function getUserProfilePanelHeaderContent({
logCopyValue?: string | null;
logSubtitle?: string | null;
onBack: () => void;
onClose: () => void;
view: ProfilePanelView;
viewerIsOwner: boolean;
}) {
@@ -37,40 +34,20 @@ export function getUserProfilePanelHeaderContent({
(view === "diagnostics" || view === "logs") && Boolean(logSubtitle);
const headerLeftContent = (
<AuxiliaryPanelHeaderGroup
className={shouldShowLogDetails ? "items-start" : undefined}
align={shouldShowLogDetails ? "start" : "center"}
backButtonAriaLabel="Back to profile"
backButtonTestId="user-profile-panel-back"
onBack={view !== "summary" ? onBack : undefined}
>
{view !== "summary" ? (
<Button
aria-label="Back to profile"
className={shouldShowLogDetails ? "mt-0.5 shrink-0" : "shrink-0"}
data-testid="user-profile-panel-back"
onClick={onBack}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft />
</Button>
) : null}
{shouldShowLogDetails ? (
<div className="min-w-0 flex-1">
<AuxiliaryPanelTitle className="translate-y-0 leading-5">
{title}
</AuxiliaryPanelTitle>
<p
className="min-w-0 truncate font-mono text-2xs text-muted-foreground"
title={logSubtitle ?? undefined}
>
{logSubtitle}
</p>
</div>
) : (
<AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>
)}
<AuxiliaryPanelHeaderTitleBlock
subtitle={shouldShowLogDetails ? logSubtitle : null}
subtitleTitle={logSubtitle ?? undefined}
title={title}
/>
</AuxiliaryPanelHeaderGroup>
);
const headerActions = (
<div className="ml-auto flex shrink-0 items-center gap-2">
<AuxiliaryPanelHeaderActions>
{view === "memories" && viewerIsOwner && effectivePubkey ? (
<MemoryRefreshButton
agentPubkey={effectivePubkey}
@@ -89,17 +66,7 @@ export function getUserProfilePanelHeaderContent({
variant="ghost"
/>
) : null}
<Button
aria-label="Close profile"
data-testid="user-profile-panel-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</div>
</AuxiliaryPanelHeaderActions>
);
return { headerActions, headerLeftContent };
+6 -2
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
const MOBILE_BREAKPOINT = 768;
@@ -86,6 +86,10 @@ export function useIsMobile() {
return useMediaBreakpoint(MOBILE_BREAKPOINT);
}
export function useIsAuxiliaryPanelOverlay() {
return useMediaBreakpoint(AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX);
}
export function useIsThreadPanelOverlay() {
return useMediaBreakpoint(THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX);
return useIsAuxiliaryPanelOverlay();
}
+14 -13
View File
@@ -1,38 +1,39 @@
import * as React from "react";
const THREAD_PANEL_DEFAULT_WIDTH_PX = 380;
export const THREAD_PANEL_MIN_WIDTH_PX = 300;
export const THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX =
THREAD_PANEL_MIN_WIDTH_PX * 2;
const THREAD_PANEL_MAX_WIDTH_PX = 720;
import {
AUXILIARY_PANEL_DEFAULT_WIDTH_PX,
AUXILIARY_PANEL_MAX_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
} from "@/shared/layout/AuxiliaryPanel";
const THREAD_PANEL_WIDTH_SESSION_KEY = "buzz.desktop.thread-panel-width";
function clampThreadPanelWidth(width: number): number {
return Math.max(
THREAD_PANEL_MIN_WIDTH_PX,
Math.min(THREAD_PANEL_MAX_WIDTH_PX, width),
AUXILIARY_PANEL_MIN_WIDTH_PX,
Math.min(AUXILIARY_PANEL_MAX_WIDTH_PX, width),
);
}
function getInitialThreadPanelWidth(): number {
if (typeof window === "undefined") {
return THREAD_PANEL_DEFAULT_WIDTH_PX;
return AUXILIARY_PANEL_DEFAULT_WIDTH_PX;
}
try {
const raw = window.sessionStorage.getItem(THREAD_PANEL_WIDTH_SESSION_KEY);
if (!raw) {
return THREAD_PANEL_DEFAULT_WIDTH_PX;
return AUXILIARY_PANEL_DEFAULT_WIDTH_PX;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return THREAD_PANEL_DEFAULT_WIDTH_PX;
return AUXILIARY_PANEL_DEFAULT_WIDTH_PX;
}
return clampThreadPanelWidth(parsed);
} catch {
return THREAD_PANEL_DEFAULT_WIDTH_PX;
return AUXILIARY_PANEL_DEFAULT_WIDTH_PX;
}
}
@@ -87,11 +88,11 @@ export function useThreadPanelWidth() {
);
const onResetWidth = React.useCallback(() => {
setWidthPx(THREAD_PANEL_DEFAULT_WIDTH_PX);
setWidthPx(AUXILIARY_PANEL_DEFAULT_WIDTH_PX);
}, []);
return {
canReset: widthPx !== THREAD_PANEL_DEFAULT_WIDTH_PX,
canReset: widthPx !== AUXILIARY_PANEL_DEFAULT_WIDTH_PX,
onResetWidth,
onResizeStart,
widthPx,
@@ -0,0 +1,28 @@
export { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanelShell";
export { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanelBody";
export {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelHeaderTitleBlock,
AuxiliaryPanelTitle,
type AuxiliaryPanelMode,
getAuxiliaryPanelBodyClass,
getAuxiliaryPanelMode,
} from "@/shared/layout/AuxiliaryPanelHeader";
export {
AuxiliaryPanelContext,
requireAuxiliaryPanelContext,
resolveAuxiliaryPanelBodyMode,
useAuxiliaryPanel,
} from "@/shared/layout/auxiliaryPanelContext";
export type {
AuxiliaryPanelContextValue,
AuxiliaryPanelLayout,
} from "@/shared/layout/auxiliaryPanelContext";
export {
AUXILIARY_PANEL_DEFAULT_WIDTH_PX,
AUXILIARY_PANEL_MAX_WIDTH_PX,
AUXILIARY_PANEL_MIN_WIDTH_PX,
AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
} from "@/shared/layout/auxiliaryPanelLayout";
@@ -0,0 +1,46 @@
import * as React from "react";
import {
AuxiliaryPanelContext,
resolveAuxiliaryPanelBodyMode,
} from "@/shared/layout/auxiliaryPanelContext";
import type { AuxiliaryPanelMode } from "@/shared/layout/auxiliaryPanelContext";
import { getAuxiliaryPanelBodyClass } from "@/shared/layout/AuxiliaryPanelHeader";
import { cn } from "@/shared/lib/cn";
type AuxiliaryPanelBodyProps = Omit<
React.ComponentProps<"div">,
"className"
> & {
className?: string;
/** Override mode when rendered outside `AuxiliaryPanel` (e.g. Radix dialog content). */
mode?: AuxiliaryPanelMode;
/** Apply top padding in floating overlay (`panel`) mode. */
panelPadding?: boolean;
};
/** Scroll/content region for auxiliary panels with consistent chrome padding. */
export function AuxiliaryPanelBody({
className,
mode: modeOverride,
panelPadding = false,
...props
}: AuxiliaryPanelBodyProps) {
const context = React.useContext(AuxiliaryPanelContext);
const mode = resolveAuxiliaryPanelBodyMode({
context,
mode: modeOverride,
});
return (
<div
className={cn(
"min-h-0 flex-1",
getAuxiliaryPanelBodyClass({ mode }),
panelPadding && mode === "panel" && "pt-4",
className,
)}
{...props}
/>
);
}
@@ -1,66 +1,362 @@
import type * as React from "react";
import * as React from "react";
import { ArrowLeft, X } from "lucide-react";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { AuxiliaryPanelContext } from "@/shared/layout/auxiliaryPanelContext";
import type { AuxiliaryPanelMode } from "@/shared/layout/auxiliaryPanelContext";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type AuxiliaryPanelHeaderProps = React.ComponentProps<"div"> & {
export type { AuxiliaryPanelMode } from "@/shared/layout/auxiliaryPanelContext";
type AuxiliaryPanelHeaderProps = Omit<
React.ComponentProps<"div">,
"className"
> & {
backdrop?: boolean;
backdropSurface?: AuxiliaryPanelSurface;
bordered?: boolean;
density?: "comfortable" | "compact";
inset?: "default" | "wide";
mode?: AuxiliaryPanelMode;
resizeBorder?: boolean;
surface?: AuxiliaryPanelSurface;
/** Render header content without its own backdrop for a shared parent chrome. */
transparent?: boolean;
};
type AuxiliaryPanelHeaderGroupProps = React.ComponentProps<"div">;
type AuxiliaryPanelTitleProps = React.ComponentProps<"h2">;
type AuxiliaryPanelHeaderGroupProps = Omit<
React.ComponentProps<"div">,
"className"
> & {
align?: "center" | "start";
backButtonAriaLabel?: string;
backButtonTestId?: string;
mode?: AuxiliaryPanelMode;
onBack?: () => void;
};
type AuxiliaryPanelHeaderActionsProps = {
children?: React.ReactNode;
includeCloseAction?: boolean;
};
type AuxiliaryPanelHeaderTitleBlockProps = {
subtitle?: React.ReactNode;
subtitleTitle?: string;
title: React.ReactNode;
};
type AuxiliaryPanelTitleProps = Omit<React.ComponentProps<"h2">, "className">;
type AuxiliaryPanelTitleContentProps = React.ComponentProps<"h2">;
type AuxiliaryPanelSurface = "default" | "soft" | "transparent";
/** Compact title/action row for right auxiliary panels in split layouts. */
const AUXILIARY_PANEL_HEADER_HEIGHT_CLASS = "pt-13";
const AUXILIARY_PANEL_CLOSE_LABEL = "Close panel";
const AUXILIARY_PANEL_CLOSE_TEST_ID = "auxiliary-panel-close";
const AUXILIARY_PANEL_RESIZE_BORDER_CLASS =
"after:absolute after:bottom-0 after:-left-px after:top-0 after:w-px after:bg-border/45 after:transition-colors peer-hover/auxiliary-panel-resize:after:bg-border/80 peer-focus-visible/auxiliary-panel-resize:after:bg-border/80";
export function getAuxiliaryPanelMode(
isSplitLayout: boolean,
isFloatingOverlay: boolean,
): AuxiliaryPanelMode {
if (isSplitLayout) {
return "docked";
}
return isFloatingOverlay ? "panel" : "single-panel";
}
function getAuxiliaryPanelSurfaceClass(surface: AuxiliaryPanelSurface) {
if (surface === "transparent") {
return "bg-transparent";
}
if (surface === "soft") {
return "bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35";
}
return "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";
}
type AuxiliaryPanelHeaderBackdropProps = {
surface: Exclude<AuxiliaryPanelSurface, "transparent">;
};
function AuxiliaryPanelHeaderBackdrop({
surface,
}: AuxiliaryPanelHeaderBackdropProps) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-40 h-13",
getAuxiliaryPanelSurfaceClass(surface),
)}
/>
);
}
/** Title/action row for right auxiliary panels across docked and standalone modes. */
export function AuxiliaryPanelHeader({
className,
backdrop = false,
backdropSurface = "default",
bordered = false,
children,
transparent = false,
density = "comfortable",
inset = "default",
mode,
resizeBorder = false,
surface = "default",
transparent,
...props
}: AuxiliaryPanelHeaderProps) {
const panelContext = React.useContext(AuxiliaryPanelContext);
const resolvedMode = mode ?? panelContext?.mode ?? "docked";
const resolvedTransparent =
transparent ?? panelContext?.transparentChrome ?? false;
if (resolvedMode !== "docked") {
const isSinglePanel = resolvedMode === "single-panel";
const effectiveSurface = resolvedTransparent ? "transparent" : surface;
return (
<>
{backdrop && backdropSurface !== "transparent" ? (
<AuxiliaryPanelHeaderBackdrop surface={backdropSurface} />
) : null}
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanel
? cn(
"relative z-41 -mb-13 min-h-13 shrink-0 gap-2.5 px-4 py-2 sm:pr-3",
inset === "wide" && "sm:pl-6",
resizeBorder && AUXILIARY_PANEL_RESIZE_BORDER_CLASS,
getAuxiliaryPanelSurfaceClass(effectiveSurface),
)
: resizeBorder
? cn(
"absolute inset-x-0 top-0 z-50 min-h-13 gap-3 bg-transparent px-3 py-2",
AUXILIARY_PANEL_RESIZE_BORDER_CLASS,
)
: cn(
"relative z-50 shrink-0 gap-3",
density === "compact"
? "min-h-11 px-3 py-1.5 text-left shadow-none"
: "min-h-13 px-5 py-2",
inset === "wide" && "sm:pl-6",
bordered && "border-b border-border/35",
getAuxiliaryPanelSurfaceClass(effectiveSurface),
),
)}
data-tauri-drag-region
{...props}
>
{renderAuxiliaryPanelHeaderContent(children)}
</div>
</>
);
}
return (
<div
className={cn(
"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",
getAuxiliaryPanelSurfaceClass(
resolvedTransparent ? "transparent" : surface,
),
channelChrome.negativeMargin,
className,
)}
{...props}
>
<div
className="pointer-events-auto relative z-40 shrink-0 cursor-default select-none px-5 py-2"
className="pointer-events-auto relative z-40 shrink-0 cursor-default select-none py-2 pl-5 pr-3"
data-tauri-drag-region
>
<div className="flex h-9 min-w-0 items-center gap-2.5">{children}</div>
<div className="flex h-9 min-w-0 items-center gap-2.5">
{renderAuxiliaryPanelHeaderContent(children)}
</div>
</div>
</div>
);
}
export const auxiliaryPanelContentPaddingClass = channelChrome.contentPadding;
function renderAuxiliaryPanelHeaderContent(children: React.ReactNode) {
const { foundActions, content } = attachCloseActionToHeaderActions(children);
if (foundActions) {
return content;
}
return (
<>
{children}
<AuxiliaryPanelHeaderActions includeCloseAction />
</>
);
}
function attachCloseActionToHeaderActions(children: React.ReactNode): {
content: React.ReactNode;
foundActions: boolean;
} {
let foundActions = false;
const content = React.Children.map(children, (child) => {
if (!React.isValidElement<AuxiliaryPanelHeaderActionsProps>(child)) {
return child;
}
if (child.type === AuxiliaryPanelHeaderActions) {
foundActions = true;
return React.cloneElement(child, { includeCloseAction: true });
}
if (child.type === React.Fragment) {
const nested = attachCloseActionToHeaderActions(child.props.children);
if (!nested.foundActions) {
return child;
}
foundActions = true;
return React.cloneElement(child, undefined, nested.content);
}
return child;
});
return { content, foundActions };
}
export function getAuxiliaryPanelBodyClass({
isFloatingOverlay = false,
isSplitLayout = false,
mode,
}: {
isFloatingOverlay?: boolean;
isSplitLayout?: boolean;
mode?: AuxiliaryPanelMode;
}) {
const resolvedMode =
mode ?? getAuxiliaryPanelMode(isSplitLayout, isFloatingOverlay);
return cn(
resolvedMode === "docked" && channelChrome.contentPadding,
resolvedMode === "single-panel" && AUXILIARY_PANEL_HEADER_HEIGHT_CLASS,
);
}
export function AuxiliaryPanelHeaderGroup({
className,
align = "center",
backButtonAriaLabel = "Back",
backButtonTestId,
mode,
children,
onBack,
...props
}: AuxiliaryPanelHeaderGroupProps) {
const panelContext = React.useContext(AuxiliaryPanelContext);
const resolvedMode = mode ?? panelContext?.mode ?? "docked";
const isOverlayLayout = resolvedMode === "panel";
return (
<div
className={cn("flex min-w-0 flex-1 items-center gap-1.5", className)}
className={cn(
"flex min-w-0 flex-1 gap-1.5",
align === "start" ? "items-start" : "items-center",
)}
{...props}
>
{onBack ? (
<Button
aria-label={backButtonAriaLabel}
// Header text needs a comfortable left inset in split layouts, but a
// leading icon should visually sit closer to the panel edge. Overlay
// headers already use compact row padding, so keep that button flush.
className={cn("shrink-0", isOverlayLayout ? "ml-0" : "-ml-2")}
data-testid={backButtonTestId}
onClick={onBack}
size="icon"
type="button"
variant={isOverlayLayout ? "ghost" : "outline"}
>
<ArrowLeft />
</Button>
) : null}
{children}
</div>
);
}
export function AuxiliaryPanelTitle({
export function AuxiliaryPanelHeaderActions({
children,
includeCloseAction = false,
}: AuxiliaryPanelHeaderActionsProps) {
if (!children && !includeCloseAction) {
return null;
}
return (
<div className="ml-auto flex shrink-0 items-center gap-0.5">
{children}
{includeCloseAction ? <AuxiliaryPanelHeaderCloseAction /> : null}
</div>
);
}
function AuxiliaryPanelHeaderCloseAction() {
const panelContext = React.useContext(AuxiliaryPanelContext);
if (!panelContext?.onClose) {
return null;
}
return (
<Button
aria-label={AUXILIARY_PANEL_CLOSE_LABEL}
className="shrink-0"
data-testid={AUXILIARY_PANEL_CLOSE_TEST_ID}
onClick={panelContext.onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
);
}
export function AuxiliaryPanelHeaderTitleBlock({
subtitle,
subtitleTitle,
title,
}: AuxiliaryPanelHeaderTitleBlockProps) {
if (!subtitle) {
return <AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>;
}
return (
<div className="min-w-0 flex-1">
<AuxiliaryPanelTitleContent className="translate-y-0 leading-5">
{title}
</AuxiliaryPanelTitleContent>
<p
className="min-w-0 truncate font-mono text-2xs text-muted-foreground"
title={subtitleTitle}
>
{subtitle}
</p>
</div>
);
}
export function AuxiliaryPanelTitle(props: AuxiliaryPanelTitleProps) {
return <AuxiliaryPanelTitleContent {...props} />;
}
function AuxiliaryPanelTitleContent({
className,
children,
...props
}: AuxiliaryPanelTitleProps) {
}: AuxiliaryPanelTitleContentProps) {
return (
<h2
className={cn(
@@ -0,0 +1,156 @@
import * as React from "react";
import { useIsAuxiliaryPanelOverlay } from "@/shared/hooks/use-mobile";
import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/auxiliaryPanelLayout";
import {
AuxiliaryPanelContext,
type AuxiliaryPanelLayout,
} from "@/shared/layout/auxiliaryPanelContext";
import { getAuxiliaryPanelMode } from "@/shared/layout/AuxiliaryPanelHeader";
import { cn } from "@/shared/lib/cn";
import {
OverlayPanelBackdrop,
PANEL_ENTER_BASE_CLASS,
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
export type {
AuxiliaryPanelContextValue,
AuxiliaryPanelLayout,
} from "@/shared/layout/auxiliaryPanelContext";
export { useAuxiliaryPanel } from "@/shared/layout/auxiliaryPanelContext";
type AuxiliaryPanelProps = {
canResetWidth?: boolean;
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
header?: React.ReactNode;
isSinglePanelView?: boolean;
layout?: AuxiliaryPanelLayout;
onClose: () => void;
onResetWidth?: () => void;
onResizeStart?: React.PointerEventHandler<HTMLButtonElement>;
resizeHandleAriaLabel?: string;
resizeHandleTestId?: string;
siblings?: React.ReactNode;
/** When false, standalone width uses `widthPx` without min-width clamp. */
splitPaneClamp?: boolean;
testId?: string;
transparentChrome?: boolean;
widthPx: number;
};
/** Right-side auxiliary panel shell for split and standalone overlay layouts. */
export function AuxiliaryPanel({
canResetWidth,
children,
className,
footer,
header,
isSinglePanelView = false,
layout = "standalone",
onClose,
onResetWidth,
onResizeStart,
resizeHandleAriaLabel = "Resize panel",
resizeHandleTestId,
siblings,
splitPaneClamp = true,
testId,
transparentChrome = false,
widthPx,
}: AuxiliaryPanelProps) {
const isOverlay = useIsAuxiliaryPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
const mode = getAuxiliaryPanelMode(isSplitLayout, isFloatingOverlay);
const contextValue = React.useMemo(
() => ({
isFloatingOverlay,
isOverlay,
isSinglePanelView,
isSplitLayout,
layout,
mode,
onClose,
transparentChrome,
widthPx,
}),
[
isFloatingOverlay,
isOverlay,
isSinglePanelView,
isSplitLayout,
layout,
mode,
onClose,
transparentChrome,
widthPx,
],
);
const panelWidth = isSinglePanelView
? "100%"
: splitPaneClamp
? `min(${widthPx}px, calc(100% - ${AUXILIARY_PANEL_MIN_WIDTH_PX}px))`
: `${widthPx}px`;
const resizeHandle =
!isSplitLayout &&
!isOverlay &&
!isSinglePanelView &&
onResizeStart != null ? (
<button
aria-label={resizeHandleAriaLabel}
className="peer/auxiliary-panel-resize group/auxiliary-panel-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
data-testid={resizeHandleTestId}
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/auxiliary-panel-resize:bg-border/80 group-focus-visible/auxiliary-panel-resize:bg-border/80" />
</button>
) : null;
if (isSplitLayout) {
return (
<AuxiliaryPanelContext.Provider value={contextValue}>
<div className={cn("flex min-h-0 flex-1 flex-col", className)}>
{header}
{children}
{footer}
</div>
{siblings}
</AuxiliaryPanelContext.Provider>
);
}
return (
<AuxiliaryPanelContext.Provider value={contextValue}>
{isFloatingOverlay ? <OverlayPanelBackdrop onClose={onClose} /> : null}
<aside
className={cn(
PANEL_ENTER_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
className,
)}
data-testid={testId}
style={{ width: panelWidth }}
>
{resizeHandle}
{header}
{children}
{footer}
</aside>
{siblings}
</AuxiliaryPanelContext.Provider>
);
}
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AuxiliaryPanel } from "./AuxiliaryPanel/index.ts";
import { AuxiliaryPanelBody } from "./AuxiliaryPanel/index.ts";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderGroup,
} from "./AuxiliaryPanel/index.ts";
import {
AuxiliaryPanelContext,
useAuxiliaryPanel,
} from "./AuxiliaryPanel/index.ts";
function render(element) {
return renderToStaticMarkup(element);
}
test("AuxiliaryPanel provides layout mode through context", () => {
function ContextProbe() {
const context = useAuxiliaryPanel();
return React.createElement(
"span",
null,
`${context.mode}:${context.layout}:${context.isSplitLayout}`,
);
}
const html = render(
React.createElement(
AuxiliaryPanel,
{
layout: "split",
onClose: () => {},
widthPx: 420,
},
React.createElement(ContextProbe),
),
);
assert.match(html, /docked:split:true/);
});
test("AuxiliaryPanelBody accepts a mode override and applies panel padding", () => {
const html = render(
React.createElement(
AuxiliaryPanelBody,
{
className: "overflow-y-auto",
mode: "panel",
panelPadding: true,
},
"Panel body",
),
);
assert.match(html, /min-h-0/);
assert.match(html, /flex-1/);
assert.match(html, /pt-4/);
assert.match(html, /overflow-y-auto/);
assert.match(html, />Panel body</);
});
test("AuxiliaryPanelBody resolves mode from context", () => {
const html = render(
React.createElement(
AuxiliaryPanelContext.Provider,
{
value: {
isFloatingOverlay: false,
isOverlay: false,
isSinglePanelView: false,
isSplitLayout: false,
layout: "standalone",
mode: "single-panel",
onClose: () => {},
transparentChrome: false,
widthPx: 360,
},
},
React.createElement(AuxiliaryPanelBody, null, "Body"),
),
);
assert.match(html, /pt-13/);
});
test("AuxiliaryPanelBody throws without a mode or provider", () => {
assert.throws(
() => render(React.createElement(AuxiliaryPanelBody, null, "Body")),
/AuxiliaryPanelBody requires `mode` or an AuxiliaryPanel ancestor/,
);
});
test("useAuxiliaryPanel throws outside AuxiliaryPanel", () => {
function HookProbe() {
useAuxiliaryPanel();
return React.createElement("span", null, "unreachable");
}
assert.throws(
() => render(React.createElement(HookProbe)),
/useAuxiliaryPanel must be used within AuxiliaryPanel/,
);
});
test("AuxiliaryPanelHeaderGroup derives overlay button styling from context", () => {
const html = render(
React.createElement(
AuxiliaryPanelContext.Provider,
{
value: {
isFloatingOverlay: true,
isOverlay: true,
isSinglePanelView: false,
isSplitLayout: false,
layout: "standalone",
mode: "panel",
onClose: () => {},
transparentChrome: false,
widthPx: 360,
},
},
React.createElement(
AuxiliaryPanelHeader,
null,
React.createElement(
AuxiliaryPanelHeaderGroup,
{ onBack: () => {} },
"Title",
),
),
),
);
assert.match(html, /ml-0/);
assert.doesNotMatch(html, /-ml-2/);
});
test("AuxiliaryPanel applies className in standalone layout", () => {
const html = render(
React.createElement(
AuxiliaryPanel,
{
className: "custom-panel-class",
onClose: () => {},
widthPx: 420,
},
"Panel",
),
);
assert.match(html, /custom-panel-class/);
});
test("AuxiliaryPanelHeader renders a generic close action from context", () => {
const html = render(
React.createElement(
AuxiliaryPanel,
{
header: React.createElement(
AuxiliaryPanelHeader,
null,
React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"),
),
onClose: () => {},
widthPx: 420,
},
"Panel",
),
);
assert.match(html, /aria-label="Close panel"/);
assert.match(html, /data-testid="auxiliary-panel-close"/);
});
test("AuxiliaryPanelHeader keeps resize border in single-panel mode when requested", () => {
const html = render(
React.createElement(
AuxiliaryPanel,
{
header: React.createElement(
AuxiliaryPanelHeader,
{ resizeBorder: true },
React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"),
),
onClose: () => {},
onResizeStart: () => {},
widthPx: 420,
},
"Panel",
),
);
assert.match(html, /after:-left-px/);
assert.match(html, /peer-hover\/auxiliary-panel-resize:after:bg-border\/80/);
});
test("AuxiliaryPanelHeader omits resize border in single-panel mode by default", () => {
const html = render(
React.createElement(
AuxiliaryPanel,
{
header: React.createElement(
AuxiliaryPanelHeader,
null,
React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"),
),
onClose: () => {},
onResizeStart: () => {},
widthPx: 420,
},
"Panel",
),
);
assert.doesNotMatch(html, /after:-left-px/);
assert.doesNotMatch(
html,
/peer-hover\/auxiliary-panel-resize:after:bg-border\/80/,
);
});
test("AuxiliaryPanel resize handle uses a generic namespace", () => {
const html = render(
React.createElement(
AuxiliaryPanel,
{
onClose: () => {},
onResizeStart: () => {},
widthPx: 420,
},
"Panel",
),
);
assert.match(html, /peer\/auxiliary-panel-resize/);
assert.match(html, /group\/auxiliary-panel-resize/);
assert.doesNotMatch(html, /profile-resize/);
});
@@ -0,0 +1,52 @@
import * as React from "react";
export type AuxiliaryPanelMode = "docked" | "panel" | "single-panel";
export type AuxiliaryPanelLayout = "standalone" | "split";
export type AuxiliaryPanelContextValue = {
isFloatingOverlay: boolean;
isOverlay: boolean;
isSinglePanelView: boolean;
isSplitLayout: boolean;
layout: AuxiliaryPanelLayout;
mode: AuxiliaryPanelMode;
onClose: () => void;
transparentChrome: boolean;
widthPx: number;
};
export const AuxiliaryPanelContext =
React.createContext<AuxiliaryPanelContextValue | null>(null);
export function requireAuxiliaryPanelContext(
context: AuxiliaryPanelContextValue | null,
): AuxiliaryPanelContextValue {
if (!context) {
throw new Error("useAuxiliaryPanel must be used within AuxiliaryPanel");
}
return context;
}
export function resolveAuxiliaryPanelBodyMode({
context,
mode,
}: {
context: AuxiliaryPanelContextValue | null;
mode?: AuxiliaryPanelMode;
}): AuxiliaryPanelMode {
const resolvedMode = mode ?? context?.mode;
if (resolvedMode == null) {
throw new Error(
"AuxiliaryPanelBody requires `mode` or an AuxiliaryPanel ancestor",
);
}
return resolvedMode;
}
/** Read chrome/layout state from the nearest `AuxiliaryPanel` ancestor. */
export function useAuxiliaryPanel(): AuxiliaryPanelContextValue {
return requireAuxiliaryPanelContext(React.useContext(AuxiliaryPanelContext));
}
@@ -0,0 +1,5 @@
export const AUXILIARY_PANEL_DEFAULT_WIDTH_PX = 380;
export const AUXILIARY_PANEL_MIN_WIDTH_PX = 300;
export const AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX =
AUXILIARY_PANEL_MIN_WIDTH_PX * 2;
export const AUXILIARY_PANEL_MAX_WIDTH_PX = 720;
@@ -25,12 +25,6 @@ export const PANEL_ENTER_MOTION_CLASS = "buzz-side-panel-enter";
export const PANEL_ENTER_BASE_CLASS = `${PANEL_BASE_CLASS} ${PANEL_ENTER_MOTION_CLASS}`;
/**
* Single-column panel headers should render above the local panel backdrop
* (z-40) but stay below global top chrome controls (z-[45]).
*/
export const PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS = "z-[41]";
type OverlayPanelBackdropProps = {
onClose: () => void;
};
+55 -8
View File
@@ -1,5 +1,7 @@
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const srcRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -11,6 +13,28 @@ const repoRoot = path.resolve(
"..",
);
function resolveSourcePath(basePath) {
if (path.extname(basePath)) {
return basePath;
}
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mjs"]) {
const candidate = `${basePath}${extension}`;
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mjs"]) {
const candidate = path.join(basePath, `index${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return `${basePath}.ts`;
}
export function resolve(specifier, context, nextResolve) {
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "preview-features.json");
@@ -19,13 +43,11 @@ export function resolve(specifier, context, nextResolve) {
if (specifier.startsWith("@/")) {
const stripped = specifier.slice(2);
// Preserve explicit extensions (.mjs, .js, .json, .ts, etc.). The bundler
// tolerates extensionless `@/` imports for .ts files; node's ESM resolver
// does not, so we only synthesize `.ts` when the specifier has no
// extension. Otherwise paths like `@/.../foo.mjs` would be coerced into
// `foo.mjs.ts` and fail to resolve.
const resolved = path.extname(stripped)
? `${srcRoot}/${stripped}`
: `${srcRoot}/${stripped}.ts`;
// tolerates extensionless `@/` imports for source files; node's ESM
// resolver does not, so resolve against the extensions the app uses.
// Otherwise paths like `@/.../foo.mjs` would be coerced into `foo.mjs.ts`
// and fail to resolve.
const resolved = resolveSourcePath(`${srcRoot}/${stripped}`);
return nextResolve(resolved, context);
}
// Resolve extensionless relative TS imports (e.g. `./parseImeta`) — the app's
@@ -37,8 +59,33 @@ export function resolve(specifier, context, nextResolve) {
!path.extname(specifier) &&
context.parentURL
) {
const resolved = new URL(`${specifier}.ts`, context.parentURL).href;
const parentPath = fileURLToPath(context.parentURL);
const resolved = resolveSourcePath(
path.resolve(path.dirname(parentPath), specifier),
);
return nextResolve(resolved, context);
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.endsWith(".tsx")) {
const source = fs.readFileSync(fileURLToPath(url), "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2020,
},
fileName: fileURLToPath(url),
});
return {
format: "module",
shortCircuit: true,
source: transpiled.outputText,
};
}
return nextLoad(url, context);
}
+1 -1
View File
@@ -152,7 +152,7 @@ test.describe("channel controls", () => {
page.getByRole("dialog", { name: "Edit channel" }),
).toHaveCount(0);
await page.getByTestId("channel-management-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(
page.getByTestId("channel-management-sheet"),
).not.toBeVisible();
+1 -1
View File
@@ -41,7 +41,7 @@ async function openChannelManagement(
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
+1 -1
View File
@@ -40,7 +40,7 @@ async function openChannelEditDialog(page: import("@playwright/test").Page) {
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
+2 -2
View File
@@ -612,7 +612,7 @@ test("opens a single-level thread panel with inline expansion", async ({
)
.toBe(rootSummaryWidthBeforeHover);
await threadPanel.getByTestId("message-thread-close").click();
await threadPanel.getByTestId("auxiliary-panel-close").click();
await expect(threadPanel).toBeHidden();
await rootSummaryRow.click();
@@ -764,7 +764,7 @@ test("thread panel width uses session storage and reset handle", async ({
})
.toBe(defaultWidthPx);
await threadPanel.getByTestId("message-thread-close").click();
await threadPanel.getByTestId("auxiliary-panel-close").click();
await expect(threadPanel).toBeHidden();
await rootMessage.hover();
+2 -2
View File
@@ -164,7 +164,7 @@ test("back undoes closing a thread panel", async ({ page }) => {
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await threadPanel.getByRole("button", { name: "Close thread" }).click();
await threadPanel.getByRole("button", { name: "Close panel" }).click();
await expect(threadPanel).not.toBeVisible();
await page.getByTestId("global-back").click();
@@ -332,7 +332,7 @@ test("message links reopen a closed thread when the same messageId is already in
"Welcome to #general",
);
await threadPanel.getByRole("button", { name: "Close thread" }).click();
await threadPanel.getByRole("button", { name: "Close panel" }).click();
await expect(threadPanel).not.toBeVisible();
const link =
+12 -12
View File
@@ -141,7 +141,7 @@ test.describe("thread unread indicator", () => {
.getByTestId("message-thread-panel")
.getByTestId("thread-collapse-guide"),
).toHaveCount(0);
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Switch away so general becomes inactive
@@ -187,7 +187,7 @@ test.describe("thread unread indicator", () => {
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Switch away
@@ -263,7 +263,7 @@ test.describe("thread unread indicator", () => {
// badge render gate and read-on-open gate must stay aligned.
await page.locator(`[data-thread-head-id="${rootEvent.id}"]`).click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await expect(badges).toHaveCount(0);
});
@@ -322,7 +322,7 @@ test.describe("thread unread indicator", () => {
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expandReply(page, r1.id);
await expandReply(page, r2.id);
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Switch away, then emit the deeper replies past the frontier — these are
@@ -437,7 +437,7 @@ test.describe("thread unread indicator", () => {
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Switch away, then emit two unread replies deep under p (children of c) —
@@ -528,7 +528,7 @@ test.describe("thread unread indicator", () => {
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await page.getByTestId("channel-random").click();
@@ -603,7 +603,7 @@ test.describe("thread unread indicator", () => {
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await page.getByTestId("channel-random").click();
@@ -680,7 +680,7 @@ test.describe("thread unread indicator", () => {
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Leave, emit unread replies, return — badge appears (same as test 01).
@@ -733,7 +733,7 @@ test.describe("thread unread indicator", () => {
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
// Leave, emit an unread reply (thread-reply-only unread), then RE-ENTER
// general so the channel-open marker fires while the reply is unread.
@@ -822,7 +822,7 @@ test.describe("thread unread indicator", () => {
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
// Leave, emit unread replies, return — badge appears (same as test 01).
@@ -848,7 +848,7 @@ test.describe("thread unread indicator", () => {
// recompute alone. Before the BUG-2 fix it would persist at 3 here.
await page.getByTestId("message-thread-summary").first().click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -924,7 +924,7 @@ test.describe("thread unread indicator", () => {
await expandReply(page, replyA?.id ?? "");
await expect(badge).toHaveCount(0);
await page.getByTestId("message-thread-close").click();
await page.getByTestId("auxiliary-panel-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("general");