mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
db1b617ab1
commit
e6738c5015
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user