mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(desktop): make header chrome zoom-correct and tidy split-pane (#941)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,8 @@ import {
|
||||
useHideDmMutation,
|
||||
useOpenDmMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
|
||||
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
|
||||
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
|
||||
import {
|
||||
@@ -66,6 +66,8 @@ import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
|
||||
import { joinChannel } from "@/shared/api/tauri";
|
||||
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
|
||||
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
|
||||
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
|
||||
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
|
||||
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
|
||||
@@ -182,6 +184,7 @@ export function AppShell() {
|
||||
React.useState<BrowseDialogType>(null);
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
|
||||
const mainInsetRef = React.useRef<HTMLElement>(null);
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
@@ -863,9 +866,15 @@ export function AppShell() {
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
<SidebarInset
|
||||
ref={mainInsetRef}
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
style={chromeCssVarDefaults}
|
||||
>
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
</MainInsetProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SidebarTrigger, useSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
@@ -25,8 +24,11 @@ function GlobalTopDivider() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed right-0 top-10 z-40 h-px bg-border/35"
|
||||
style={{ left: state === "expanded" ? "var(--sidebar-width)" : 0 }}
|
||||
className="pointer-events-none fixed top-10 z-40 h-px bg-border/35"
|
||||
style={{
|
||||
left: state === "expanded" ? "var(--sidebar-width)" : 0,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -49,12 +51,13 @@ function CenterColumnTopbarSearch({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed right-0 top-[7px] z-[45] flex justify-center px-24",
|
||||
!isResizing && "transition-[left] duration-200 ease-linear",
|
||||
)}
|
||||
className="pointer-events-none fixed top-[7px] z-45 flex justify-center px-24 transition-[left] duration-200 ease-linear data-[resizing=true]:transition-none"
|
||||
data-testid="topbar-search-column"
|
||||
style={{ left: state === "expanded" ? "var(--sidebar-width)" : 0 }}
|
||||
data-resizing={isResizing}
|
||||
style={{
|
||||
left: state === "expanded" ? "var(--sidebar-width)" : 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<TopbarSearch
|
||||
channels={channels}
|
||||
@@ -69,7 +72,7 @@ function CenterColumnTopbarSearch({
|
||||
}
|
||||
|
||||
const TOP_CHROME_ICON_BUTTON_CLASS =
|
||||
"size-6 rounded-[4px] p-1 text-muted-foreground/70 hover:bg-border/45 hover:text-foreground";
|
||||
"rounded-[4px] text-muted-foreground/70 hover:bg-border/45 hover:text-foreground";
|
||||
|
||||
export function AppTopChrome({
|
||||
canGoBack,
|
||||
@@ -91,7 +94,7 @@ export function AppTopChrome({
|
||||
data-tauri-drag-region
|
||||
/>
|
||||
<GlobalTopDivider />
|
||||
<div className="fixed left-[80px] top-[9px] z-[45] flex items-center gap-0.5">
|
||||
<div className="fixed left-[80px] top-[9px] z-45 flex items-center gap-0.5">
|
||||
<SidebarTrigger className={TOP_CHROME_ICON_BUTTON_CLASS} />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
@@ -102,7 +105,7 @@ export function AppTopChrome({
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
@@ -113,7 +116,7 @@ export function AppTopChrome({
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
{searchHidden ? null : (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
|
||||
import type { EngramEntry } from "@/shared/api/tauriEngrams";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Button, type ButtonProps } from "@/shared/ui/button";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
@@ -58,10 +58,12 @@ export function MemoryRefreshButton({
|
||||
agentPubkey,
|
||||
className,
|
||||
iconClassName,
|
||||
variant = "ghost",
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
variant?: ButtonProps["variant"];
|
||||
}): React.ReactElement | null {
|
||||
const isOwner = useIsManagedAgent(agentPubkey);
|
||||
const { query } = useAgentMemoryGraph(agentPubkey, {
|
||||
@@ -79,7 +81,7 @@ export function MemoryRefreshButton({
|
||||
onClick={() => query.refetch()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant={variant}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type * as React from "react";
|
||||
import { ArrowLeft, CircleDot, Octagon, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -10,6 +9,12 @@ 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 {
|
||||
AuxiliaryPanelHeader,
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
auxiliaryPanelContentPaddingClass,
|
||||
} from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
@@ -25,37 +30,33 @@ import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
||||
|
||||
type AgentSessionThreadPanelProps = {
|
||||
agent: ChannelAgentSessionAgent;
|
||||
canResetWidth: boolean;
|
||||
channel: Channel;
|
||||
canInterruptTurn: boolean;
|
||||
isWorking: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
isSinglePanelView?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
onBackToProfile: () => void;
|
||||
onClose: () => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
export function AgentSessionThreadPanel({
|
||||
agent,
|
||||
canResetWidth,
|
||||
canInterruptTurn,
|
||||
channel,
|
||||
isWorking,
|
||||
layout = "standalone",
|
||||
isSinglePanelView = false,
|
||||
profiles,
|
||||
onBackToProfile,
|
||||
onClose,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
widthPx,
|
||||
}: AgentSessionThreadPanelProps) {
|
||||
const isLive = isManagedAgentActive(agent);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const isFloatingOverlay = isOverlay && !isSinglePanelView;
|
||||
const usesChannelSplitChrome = !isOverlay && !isSinglePanelView;
|
||||
const isSplitLayout = layout === "split";
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
|
||||
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
|
||||
@@ -75,6 +76,107 @@ export function AgentSessionThreadPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const agentHeaderActions = (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{isLive && isWorking ? (
|
||||
<Badge
|
||||
className="shrink-0 gap-1 px-2 py-0 text-[10px]"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
{isLive && isWorking ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Stop current agent turn"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
data-testid="agent-session-stop-turn"
|
||||
disabled={!canInterruptTurn}
|
||||
onClick={() => {
|
||||
void handleInterruptTurn();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Octagon className="h-3 w-3" />
|
||||
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>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close activity panel"
|
||||
data-testid="agent-session-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
<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 && (isOverlay ? "pt-4" : "pt-[4.75rem]"),
|
||||
)}
|
||||
>
|
||||
<ManagedAgentSessionPanel
|
||||
agent={agent}
|
||||
channelId={channel.id}
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
emptyDescription={`Mention ${agent.name} in the 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>{agentHeaderContent}</AuxiliaryPanelHeader>
|
||||
{agentBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
@@ -91,31 +193,10 @@ export function AgentSessionThreadPanel({
|
||||
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
|
||||
}}
|
||||
>
|
||||
{!isOverlay && !isSinglePanelView && (
|
||||
<button
|
||||
aria-label="Resize agent session panel"
|
||||
className="peer/agent-session-resize group/agent-session-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
|
||||
data-testid="agent-session-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/agent-session-resize:bg-border/80 group-focus-visible/agent-session-resize:bg-border/80" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isOverlay ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-background/80 backdrop-blur-md after:absolute after:left-0 after:right-0 after:top-10 after:h-px after:bg-border/35 supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
|
||||
usesChannelSplitChrome ? "h-[92px]" : "h-[76px]",
|
||||
)}
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[4.75rem] bg-background/75 backdrop-blur-md before:absolute before:left-0 before:right-0 before:top-10 before:h-px before:bg-border/35 supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -123,130 +204,15 @@ export function AgentSessionThreadPanel({
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center",
|
||||
isSinglePanelView
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} mb-[-76px] min-h-[76px] shrink-0 gap-[10px] bg-transparent pb-[4px] pl-[16px] pr-[8px] pt-[42px] sm:pl-[24px] sm:pr-[12px]`
|
||||
: isOverlay
|
||||
? "relative z-50 min-h-[44px] shrink-0 gap-3 bg-background/80 px-3 py-[6px] 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-[48px] z-50 h-[32px] gap-[10px] bg-transparent py-0 pl-[16px] pr-[8px] after:absolute after:bottom-0 after:-left-px after:top-0 after:w-px after:bg-border/45 after:transition-colors peer-hover/agent-session-resize:after:bg-border/80 peer-focus-visible/agent-session-resize:after:bg-border/80 sm:pr-[12px]",
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-background/80 pb-1 pl-4 pr-2 pt-[2.625rem] 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-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 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
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Button
|
||||
aria-label="Back from activity"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
usesChannelSplitChrome
|
||||
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
|
||||
: "h-7 w-7 rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
data-testid="agent-session-back"
|
||||
onClick={onBackToProfile}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft
|
||||
className={cn(usesChannelSplitChrome ? "size-4" : "size-3.5")}
|
||||
/>
|
||||
</Button>
|
||||
<h2
|
||||
className={cn(
|
||||
"min-w-0 translate-y-px truncate font-semibold tracking-tight",
|
||||
usesChannelSplitChrome
|
||||
? "text-base leading-6"
|
||||
: "text-sm leading-5",
|
||||
)}
|
||||
>
|
||||
Activity
|
||||
</h2>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex shrink-0 items-center gap-2",
|
||||
isSinglePanelView && "translate-y-px",
|
||||
)}
|
||||
>
|
||||
{isLive && isWorking ? (
|
||||
<Badge
|
||||
className="shrink-0 gap-1 px-2 py-0 text-[10px]"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
{isLive && isWorking ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Stop current agent turn"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
data-testid="agent-session-stop-turn"
|
||||
disabled={!canInterruptTurn}
|
||||
onClick={() => {
|
||||
void handleInterruptTurn();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Octagon className="h-3 w-3" />
|
||||
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>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close activity panel"
|
||||
className={cn(
|
||||
usesChannelSplitChrome
|
||||
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
data-testid="agent-session-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X
|
||||
className={cn(
|
||||
usesChannelSplitChrome ? "size-5" : "h-2.5 w-2.5",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{agentHeaderContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
|
||||
// Single-panel mode keeps the 76px local header; split panes sit
|
||||
// under the channel screen's 92px top chrome.
|
||||
usesChannelSplitChrome
|
||||
? "pt-[92px]"
|
||||
: isOverlay
|
||||
? "pt-4"
|
||||
: "pt-[76px]",
|
||||
)}
|
||||
>
|
||||
<ManagedAgentSessionPanel
|
||||
agent={agent}
|
||||
channelId={channel.id}
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
|
||||
profiles={profiles}
|
||||
showHeader={false}
|
||||
showRaw={false}
|
||||
/>
|
||||
</div>
|
||||
{agentBody}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -114,13 +114,12 @@ export function ChannelMembersBar({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Channel actions"
|
||||
className="h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
data-testid="channel-actions-menu-trigger"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<EllipsisVertical className="size-5" />
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48" forceMount>
|
||||
@@ -158,7 +157,6 @@ export function ChannelMembersBar({
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<Button
|
||||
aria-label="Add agent"
|
||||
className="h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
data-testid="channel-add-bot-trigger"
|
||||
disabled={!canAddAgents}
|
||||
onClick={() => {
|
||||
@@ -166,21 +164,21 @@ export function ChannelMembersBar({
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
<Plus />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={`View channel members (${memberCount})`}
|
||||
className="h-8 gap-1 rounded-lg border border-border/40 px-2.5 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
className="h-8 px-2.5"
|
||||
data-testid="channel-members-trigger"
|
||||
onClick={onToggleMembers}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<Users className="size-5" />
|
||||
<span className="min-w-[1ch] text-[11px] font-medium tabular-nums">
|
||||
<Users />
|
||||
<span className="min-w-[1ch] text-sm font-medium tabular-nums">
|
||||
{memberCount}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -189,14 +187,13 @@ export function ChannelMembersBar({
|
||||
|
||||
<Button
|
||||
aria-label="Manage channel"
|
||||
className="h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
data-testid="channel-management-trigger"
|
||||
onClick={onManageChannel}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2 className="size-5" />
|
||||
<Settings2 />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"
|
||||
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
|
||||
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
|
||||
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import {
|
||||
BotActivityComposerAction,
|
||||
type BotActivityAgent,
|
||||
@@ -25,6 +26,9 @@ import {
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type ChannelPaneProps = {
|
||||
activeChannel: Channel | null;
|
||||
@@ -41,6 +45,7 @@ type ChannelPaneProps = {
|
||||
imetaMedia?: ImetaMedia[];
|
||||
} | null;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
header?: React.ReactNode;
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
isJoining?: boolean;
|
||||
@@ -118,6 +123,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
currentPubkey,
|
||||
editTarget = null,
|
||||
fetchOlder,
|
||||
header,
|
||||
hasOlderMessages,
|
||||
isFetchingOlder,
|
||||
followThreadById,
|
||||
@@ -323,6 +329,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
};
|
||||
}, [activeChannel, currentPubkey, profiles]);
|
||||
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
|
||||
|
||||
const selectedAgent = React.useMemo(
|
||||
() =>
|
||||
openAgentSessionPubkey
|
||||
@@ -336,16 +345,19 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
{!isSinglePanelView ? (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{header}
|
||||
{channelFind.isOpen ? (
|
||||
<ChannelFindBar
|
||||
matchCount={channelFind.matchCount}
|
||||
matchIndex={channelFind.activeIndex}
|
||||
onClose={channelFind.close}
|
||||
onNext={channelFind.goToNext}
|
||||
onPrevious={channelFind.goToPrevious}
|
||||
onQueryChange={channelFind.setQuery}
|
||||
query={channelFind.query}
|
||||
/>
|
||||
<div className={cn("absolute inset-x-0 z-40", channelChrome.top)}>
|
||||
<ChannelFindBar
|
||||
matchCount={channelFind.matchCount}
|
||||
matchIndex={channelFind.activeIndex}
|
||||
onClose={channelFind.close}
|
||||
onNext={channelFind.goToNext}
|
||||
onPrevious={channelFind.goToPrevious}
|
||||
onQueryChange={channelFind.setQuery}
|
||||
query={channelFind.query}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<MessageTimeline
|
||||
agentPubkeys={agentPubkeys}
|
||||
@@ -474,89 +486,143 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{threadHeadMessage ? (
|
||||
<MessageThreadPanel
|
||||
agentPubkeys={agentPubkeys}
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isSending={isSending}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
|
||||
onEditSave={onEditSave}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onExpandReplies={onExpandThreadReplies}
|
||||
onSelectReplyTarget={onSelectThreadReplyTarget}
|
||||
onSend={onSendThreadReply}
|
||||
onScrollTargetResolved={onThreadScrollTargetResolved}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetId={threadScrollTargetId}
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
threadHead={threadHeadMessage}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
hasThreadComposerBotActivity ? (
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
{threadHeadMessage
|
||||
? (() => {
|
||||
const panel = (
|
||||
<MessageThreadPanel
|
||||
agentPubkeys={agentPubkeys}
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isSending={isSending}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
|
||||
onEditSave={onEditSave}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onExpandReplies={onExpandThreadReplies}
|
||||
onSelectReplyTarget={onSelectThreadReplyTarget}
|
||||
onSend={onSendThreadReply}
|
||||
onScrollTargetResolved={onThreadScrollTargetResolved}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={threadComposerBotTypingPubkeys}
|
||||
variant="inline"
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetId={threadScrollTargetId}
|
||||
threadHead={threadHeadMessage}
|
||||
widthPx={threadPanelWidthPx}
|
||||
threadReplies={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
hasThreadComposerBotActivity ? (
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={threadComposerBotTypingPubkeys}
|
||||
variant="inline"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : activeChannel && selectedAgent ? (
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={activeChannel}
|
||||
isWorking={botTypingEntries.some(
|
||||
(entry) =>
|
||||
entry.pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
|
||||
)}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
profiles={profiles}
|
||||
onBackToProfile={() => onOpenProfilePanel(selectedAgent.pubkey)}
|
||||
onClose={onCloseAgentSession}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
) : profilePanelPubkey ? (
|
||||
<UserProfilePanel
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
currentPubkey={currentPubkey}
|
||||
isSinglePanelView={isSinglePanelView}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
) : null}
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="message-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: activeChannel && selectedAgent
|
||||
? (() => {
|
||||
const panel = (
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={activeChannel}
|
||||
isWorking={botTypingEntries.some(
|
||||
(entry) =>
|
||||
entry.pubkey.toLowerCase() ===
|
||||
selectedAgent.pubkey.toLowerCase(),
|
||||
)}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
profiles={profiles}
|
||||
onBackToProfile={() =>
|
||||
onOpenProfilePanel(selectedAgent.pubkey)
|
||||
}
|
||||
onClose={onCloseAgentSession}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="agent-session-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: profilePanelPubkey
|
||||
? (() => {
|
||||
const panel = (
|
||||
<UserProfilePanel
|
||||
currentPubkey={currentPubkey}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="user-profile-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,12 +45,11 @@ import { useChannelFind } from "@/features/search/useChannelFind";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
|
||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||
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 {
|
||||
useElementWidth,
|
||||
useIsThreadPanelOverlay,
|
||||
} from "@/shared/hooks/use-mobile";
|
||||
import {
|
||||
THREAD_PANEL_MIN_WIDTH_PX,
|
||||
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
|
||||
useThreadPanelWidth,
|
||||
} from "@/shared/hooks/useThreadPanelWidth";
|
||||
@@ -65,7 +64,6 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget";
|
||||
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
||||
|
||||
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
|
||||
const HEADER_ACTIONS_SPLIT_GUTTER_PX = 12;
|
||||
|
||||
export function ChannelScreen({
|
||||
activeChannel,
|
||||
@@ -98,7 +96,6 @@ export function ChannelScreen({
|
||||
widthPx: threadPanelWidthPx,
|
||||
} = useThreadPanelWidth();
|
||||
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
||||
const isThreadPanelOverlay = useIsThreadPanelOverlay();
|
||||
const [channelContentRef, channelContentWidthPx] =
|
||||
useElementWidth<HTMLDivElement>();
|
||||
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
|
||||
@@ -116,6 +113,7 @@ export function ChannelScreen({
|
||||
string | null
|
||||
>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||
const mainInsetRef = useMainInsetRef();
|
||||
const currentPubkey = currentIdentity?.pubkey;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const messagesQuery = useChannelMessagesQuery(activeChannel);
|
||||
@@ -483,58 +481,63 @@ export function ChannelScreen({
|
||||
isNarrowPanelViewport &&
|
||||
activeChannel?.channelType !== "forum" &&
|
||||
hasAuxiliaryPanel;
|
||||
const hasSplitRightPanel =
|
||||
!isSinglePanelView && !isThreadPanelOverlay && hasAuxiliaryPanel;
|
||||
const shouldCompactHeaderActions =
|
||||
hasAuxiliaryPanel &&
|
||||
channelContentWidthPx > 0 &&
|
||||
channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX;
|
||||
const splitRightPanelInset = hasSplitRightPanel
|
||||
? `min(${threadPanelWidthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
|
||||
: undefined;
|
||||
const headerActionsRightInset = splitRightPanelInset
|
||||
? `calc(${splitRightPanelInset} + ${HEADER_ACTIONS_SPLIT_GUTTER_PX}px)`
|
||||
: undefined;
|
||||
const channelHeaderChromeRef = useMeasuredCssVariable({
|
||||
targetRef: mainInsetRef,
|
||||
...channelContentTopPaddingMeasurement,
|
||||
resetKey: activeChannelId,
|
||||
enabled: !isSinglePanelView,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
setTopbarSearchHidden(isSinglePanelView);
|
||||
return () => setTopbarSearchHidden(false);
|
||||
return () => {
|
||||
setTopbarSearchHidden(false);
|
||||
};
|
||||
}, [isSinglePanelView, setTopbarSearchHidden]);
|
||||
|
||||
const channelHeader = (
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||
activeDmAvatarUrl={activeDmAvatarUrl}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
chromeWrapperRef={channelHeaderChromeRef}
|
||||
currentPubkey={currentPubkey}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
showHeaderContent={!isSinglePanelView}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
|
||||
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
actionsRightInset={headerActionsRightInset}
|
||||
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||
activeDmAvatarUrl={activeDmAvatarUrl}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
currentPubkey={currentPubkey}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
showHeaderContent={!isSinglePanelView}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
ref={channelContentRef}
|
||||
>
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<>
|
||||
{channelHeader}
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
) : (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
@@ -546,6 +549,7 @@ export function ChannelScreen({
|
||||
currentPubkey={currentPubkey}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
fetchOlder={fetchOlder}
|
||||
header={channelHeader}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LogIn } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
||||
@@ -13,10 +14,10 @@ type ChannelScreenHeaderProps = {
|
||||
activeChannel: Channel | null;
|
||||
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
|
||||
activeChannelTitle: string;
|
||||
actionsRightInset?: string;
|
||||
actionsVariant?: "inline" | "compact";
|
||||
activeDmAvatarUrl: string | null;
|
||||
activeDmPresenceStatus: PresenceStatus | null;
|
||||
chromeWrapperRef?: React.Ref<HTMLDivElement>;
|
||||
currentPubkey?: string;
|
||||
isJoining?: boolean;
|
||||
showHeaderContent?: boolean;
|
||||
@@ -29,10 +30,10 @@ export function ChannelScreenHeader({
|
||||
activeChannel,
|
||||
activeChannelEphemeralDisplay,
|
||||
activeChannelTitle,
|
||||
actionsRightInset,
|
||||
actionsVariant = "inline",
|
||||
activeDmAvatarUrl,
|
||||
activeDmPresenceStatus,
|
||||
chromeWrapperRef,
|
||||
currentPubkey,
|
||||
isJoining = false,
|
||||
showHeaderContent = true,
|
||||
@@ -76,9 +77,9 @@ export function ChannelScreenHeader({
|
||||
return (
|
||||
<ChatHeader
|
||||
belowSystemChrome
|
||||
chromeWrapperRef={chromeWrapperRef}
|
||||
density="compact"
|
||||
actions={actions}
|
||||
actionsRightInset={actionsRightInset}
|
||||
channelType={activeChannel?.channelType}
|
||||
description={getChannelDescription(activeChannel)}
|
||||
leadingContent={
|
||||
|
||||
58
desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
Normal file
58
desktop/src/features/channels/ui/RightAuxiliaryPane.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type RightAuxiliaryPaneProps = {
|
||||
canResetWidth: boolean;
|
||||
children: React.ReactNode;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
testId?: string;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
export function RightAuxiliaryPane({
|
||||
canResetWidth,
|
||||
children,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
testId,
|
||||
widthPx,
|
||||
}: RightAuxiliaryPaneProps) {
|
||||
return (
|
||||
<aside
|
||||
className="group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-(--sprout-top-chrome-height,2.5rem) before:z-40 before:w-px before:bg-border/80 before:content-['']"
|
||||
data-testid={testId}
|
||||
style={{
|
||||
maxWidth: `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`,
|
||||
width: widthPx,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label="Resize panel"
|
||||
className="peer/right-pane-resize group/right-pane-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
|
||||
data-testid="right-auxiliary-pane-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={cn(
|
||||
"absolute bottom-0 left-1/2 w-px -translate-x-1/2 bg-transparent group-hover/right-pane-resize:bg-border/80 group-focus-visible/right-pane-resize:bg-border/80",
|
||||
topChromeInset.top,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -10,17 +10,17 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
actionsPlacement?: "inline" | "top-right";
|
||||
actionsRightInset?: string;
|
||||
belowSystemChrome?: boolean;
|
||||
/** Ref to the outer chrome wrapper when `belowSystemChrome` is true. */
|
||||
chromeWrapperRef?: React.Ref<HTMLDivElement>;
|
||||
density?: "default" | "compact";
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -81,9 +81,8 @@ function ChannelIcon({
|
||||
|
||||
export function ChatHeader({
|
||||
actions,
|
||||
actionsPlacement = "inline",
|
||||
actionsRightInset,
|
||||
belowSystemChrome = false,
|
||||
chromeWrapperRef,
|
||||
density = "default",
|
||||
title,
|
||||
description,
|
||||
@@ -95,36 +94,34 @@ export function ChatHeader({
|
||||
statusBadge,
|
||||
}: ChatHeaderProps) {
|
||||
const trimmedDescription = description?.trim() ?? "";
|
||||
const topRightActions = (
|
||||
<div className="fixed right-3 top-[9px] z-[45] flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const header = (
|
||||
<header
|
||||
className={cn(
|
||||
"pointer-events-auto relative z-30 flex min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-transparent pl-[16px] pr-[8px] transition-[margin,padding] duration-200 ease-linear sm:pr-[12px]",
|
||||
"pointer-events-auto relative z-30 flex min-w-0 shrink-0 cursor-default select-none items-center gap-2.5 bg-transparent pl-4 pr-2 transition-[margin,padding] duration-200 ease-linear sm:pr-3",
|
||||
density === "compact"
|
||||
? "h-[32px] py-0"
|
||||
: "min-h-[44px] py-[6px] sm:pl-[24px]",
|
||||
overlaysContent && !belowSystemChrome && "-mb-[44px]",
|
||||
? belowSystemChrome
|
||||
? "min-h-8 py-1.5"
|
||||
: "min-h-8 py-0"
|
||||
: "min-h-11 py-1.5 sm:pl-6",
|
||||
overlaysContent && !belowSystemChrome && "-mb-11",
|
||||
)}
|
||||
data-testid="chat-header"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
|
||||
{leadingContent ?? (
|
||||
<ChannelIcon
|
||||
channelType={channelType}
|
||||
mode={mode}
|
||||
visibility={visibility}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-[4px] overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
{leadingContent ?? (
|
||||
<ChannelIcon
|
||||
channelType={channelType}
|
||||
mode={mode}
|
||||
visibility={visibility}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1
|
||||
className="min-w-0 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
|
||||
className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight"
|
||||
data-testid="chat-title"
|
||||
title={trimmedDescription || undefined}
|
||||
>
|
||||
@@ -138,24 +135,10 @@ export function ChatHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionsPlacement === "top-right" ? (
|
||||
typeof document === "undefined" ? null : (
|
||||
createPortal(topRightActions, document.body)
|
||||
)
|
||||
) : actionsRightInset ? (
|
||||
<div
|
||||
className="absolute top-1/2 z-10 flex shrink-0 -translate-y-1/2 items-center gap-1"
|
||||
style={{ right: actionsRightInset }}
|
||||
>
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -164,7 +147,14 @@ export function ChatHeader({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none relative z-30 h-[92px] -mb-[92px] bg-background/80 pb-[9px] pt-[48px] backdrop-blur-md after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border/35 after:content-[''] supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55">
|
||||
<div
|
||||
ref={chromeWrapperRef}
|
||||
className={cn(
|
||||
"pointer-events-none relative z-30 bg-background/80 backdrop-blur-md after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border/35 after:content-[''] supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
|
||||
topChromeInset.padding,
|
||||
channelChrome.negativeMargin,
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||
@@ -158,7 +159,7 @@ export function ForumThreadPanel({
|
||||
|
||||
if (isLoading || !thread) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn("flex h-full flex-col", channelChrome.contentPadding)}>
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
@@ -191,7 +192,7 @@ export function ForumThreadPanel({
|
||||
profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn("flex h-full flex-col", channelChrome.contentPadding)}>
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as React from "react";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import {
|
||||
@@ -143,7 +145,7 @@ export function ForumView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col pt-11">
|
||||
<div className={cn("flex h-full flex-col", channelChrome.contentPadding)}>
|
||||
{/* New post area */}
|
||||
<div className="border-b border-border/60 p-4">
|
||||
{isComposerOpen ? (
|
||||
|
||||
@@ -243,9 +243,9 @@ export function FeedSection({
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`pointer-events-auto absolute right-1.5 top-1.5 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100 ${isDone ? "text-green-500 opacity-100" : "text-muted-foreground"}`}
|
||||
className={`pointer-events-auto absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 ${isDone ? "text-green-500 opacity-100" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<Check />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
type HomeChannelActionsProps = {
|
||||
channel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
export function HomeChannelActions({
|
||||
channel,
|
||||
currentPubkey,
|
||||
onOpenChannel,
|
||||
}: HomeChannelActionsProps) {
|
||||
if (!channel || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed right-3 top-[9px] z-[45] flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
<ChannelMembersBar
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={() => onOpenChannel(channel.id)}
|
||||
onToggleMembers={() => onOpenChannel(channel.id)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
INBOX_SINGLE_COLUMN_BREAKPOINT_PX,
|
||||
useResizableInboxListWidth,
|
||||
} from "@/features/home/useResizableInboxListWidth";
|
||||
import { HomeChannelActions } from "@/features/home/ui/HomeChannelActions";
|
||||
import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState";
|
||||
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
|
||||
import { InboxListPane } from "@/features/home/ui/InboxListPane";
|
||||
@@ -31,16 +30,17 @@ import {
|
||||
useChannelMessagesQuery,
|
||||
useToggleReactionMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import {
|
||||
collectMessageMentionPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import { KIND_REACTION } from "@/shared/constants/kinds";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { useElementWidth } from "@/shared/hooks/use-mobile";
|
||||
@@ -333,11 +333,6 @@ export function HomeView({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<HomeChannelActions
|
||||
channel={selectedChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onOpenChannel={onOpenChannel}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid min-h-0 w-full flex-1",
|
||||
@@ -371,7 +366,8 @@ export function HomeView({
|
||||
<button
|
||||
aria-label="Resize inbox list"
|
||||
className={cn(
|
||||
"group absolute inset-y-0 z-40 w-3 -translate-x-1/2 cursor-col-resize",
|
||||
"group absolute bottom-0 z-40 w-3 -translate-x-1/2 cursor-col-resize",
|
||||
topChromeInset.top,
|
||||
showListPane && showDetailPane ? "block" : "hidden",
|
||||
)}
|
||||
data-testid="home-inbox-list-resize-handle"
|
||||
@@ -387,7 +383,7 @@ export function HomeView({
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
|
||||
<span className="absolute bottom-0 left-1/2 top-0 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
|
||||
</button>
|
||||
|
||||
{showDetailPane ? (
|
||||
@@ -398,7 +394,9 @@ export function HomeView({
|
||||
availableChannelIds.has(selectedItem.item.channelId),
|
||||
)}
|
||||
canReply={canReply}
|
||||
channel={selectedChannel}
|
||||
contextChannelName={selectedChannel?.name ?? null}
|
||||
currentPubkey={currentPubkey}
|
||||
disabledReplyReason={disabledReplyReason}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isDone={
|
||||
@@ -434,6 +432,7 @@ export function HomeView({
|
||||
setIsDeletingMessage(false);
|
||||
});
|
||||
}}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenContext={onOpenContext}
|
||||
onSendReply={async ({
|
||||
content,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
InboxItem,
|
||||
InboxReply,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||
import { formatInboxTypeLabel } from "@/features/home/lib/inbox";
|
||||
import {
|
||||
type InboxDisplayMessage,
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
} from "@/features/home/ui/InboxMessageRow";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -51,9 +54,12 @@ type InboxDetailPaneProps = {
|
||||
item: InboxItem | null;
|
||||
messages?: InboxContextMessage[];
|
||||
replies?: InboxReply[];
|
||||
channel: Channel | null;
|
||||
contextChannelName?: string | null;
|
||||
currentPubkey?: string;
|
||||
onBack?: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenContext?: (channelId: string, messageId: string) => void;
|
||||
onSendReply: (input: {
|
||||
content: string;
|
||||
@@ -82,9 +88,12 @@ export function InboxDetailPane({
|
||||
item,
|
||||
messages = [],
|
||||
replies = [],
|
||||
channel,
|
||||
contextChannelName = null,
|
||||
currentPubkey,
|
||||
onBack,
|
||||
onDelete,
|
||||
onOpenChannel,
|
||||
onOpenContext,
|
||||
onSendReply,
|
||||
onToggleReaction,
|
||||
@@ -200,13 +209,13 @@ export function InboxDetailPane({
|
||||
id: replyTarget.id,
|
||||
}
|
||||
: null;
|
||||
const channelContextName = contextChannelName ?? item.channelLabel;
|
||||
const composerChannelType =
|
||||
item.item.channelType === "dm" ||
|
||||
item.item.channelType === "stream" ||
|
||||
item.item.channelType === "forum"
|
||||
? item.item.channelType
|
||||
: null;
|
||||
const channelContextName = contextChannelName ?? item.channelLabel;
|
||||
const contextLabel = channelContextName ?? formatInboxTypeLabel(item);
|
||||
const hasChannelContext = Boolean(channelContextName);
|
||||
const contextChannelId = item.item.channelId;
|
||||
@@ -224,100 +233,90 @@ export function InboxDetailPane({
|
||||
data-testid="home-inbox-detail"
|
||||
ref={detailPaneRef}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<TopChromeBackdrop className="h-[76px]" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-[42px] z-40 min-h-[32px] py-[4px] pr-3",
|
||||
"pl-5",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 -translate-y-[4px] items-center",
|
||||
isSinglePanelView ? "gap-[4px]" : "gap-1",
|
||||
)}
|
||||
>
|
||||
{onBack ? (
|
||||
isSinglePanelView ? (
|
||||
<div className="relative h-[14px] w-[14px] shrink-0">
|
||||
<Button
|
||||
aria-label="Back to inbox list"
|
||||
className="absolute left-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full p-0 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<TopChromeInsetHeader>
|
||||
<div className="px-5 py-1 pr-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
isSinglePanelView ? "gap-[4px]" : "gap-1",
|
||||
)}
|
||||
>
|
||||
{onBack ? (
|
||||
<Button
|
||||
aria-label="Back to inbox list"
|
||||
className="h-6 w-6 rounded-full p-0 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
className="rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
{canOpenChannel && contextChannelId && onOpenContext ? (
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-[4px] text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={() => onOpenContext(contextChannelId, item.id)}
|
||||
title={item.fullTimestampLabel}
|
||||
type="button"
|
||||
>
|
||||
{hasChannelContext ? (
|
||||
<Hash
|
||||
className="h-[14px] w-[14px] shrink-0"
|
||||
color="gray"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 translate-y-px truncate">
|
||||
{contextLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<h2
|
||||
className="flex min-w-0 items-center gap-[4px] text-sm font-semibold leading-5 tracking-tight text-foreground"
|
||||
title={item.fullTimestampLabel}
|
||||
>
|
||||
{hasChannelContext ? (
|
||||
<Hash
|
||||
className="h-[14px] w-[14px] shrink-0"
|
||||
color="gray"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 translate-y-px truncate">
|
||||
{contextLabel}
|
||||
</span>
|
||||
</h2>
|
||||
)}
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
{canOpenChannel && contextChannelId && onOpenContext ? (
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-[4px] text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={() => onOpenContext(contextChannelId, item.id)}
|
||||
title={item.fullTimestampLabel}
|
||||
type="button"
|
||||
>
|
||||
{hasChannelContext ? (
|
||||
<Hash
|
||||
className="h-[14px] w-[14px] shrink-0"
|
||||
color="gray"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 translate-y-px truncate">
|
||||
{contextLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<h2
|
||||
className="flex min-w-0 items-center gap-[4px] text-sm font-semibold leading-5 tracking-tight text-foreground"
|
||||
title={item.fullTimestampLabel}
|
||||
>
|
||||
{hasChannelContext ? (
|
||||
<Hash
|
||||
className="h-[14px] w-[14px] shrink-0"
|
||||
color="gray"
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 translate-y-px truncate">
|
||||
{contextLabel}
|
||||
</span>
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{channel ? (
|
||||
<ChannelMembersBar
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={() => onOpenChannel(channel.id)}
|
||||
onToggleMembers={() => onOpenChannel(channel.id)}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderMoreMenu
|
||||
canDelete={canDelete}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isDone={isDone}
|
||||
onDelete={onDelete}
|
||||
onToggleDone={onToggleDone}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<HeaderMoreMenu
|
||||
canDelete={canDelete}
|
||||
isDeletingMessage={isDeletingMessage}
|
||||
isDone={isDone}
|
||||
onDelete={onDelete}
|
||||
onToggleDone={onToggleDone}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</TopChromeInsetHeader>
|
||||
|
||||
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-[76px]">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32">
|
||||
<div>
|
||||
{isThreadContextLoading ? (
|
||||
<div className="px-6 pb-3 text-[11px] text-muted-foreground">
|
||||
@@ -394,12 +393,12 @@ function HeaderMoreMenu({
|
||||
const trigger = (
|
||||
<Button
|
||||
aria-label="More actions"
|
||||
className="h-8 w-8 rounded-full p-0 text-muted-foreground"
|
||||
className="rounded-full text-muted-foreground"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
type InboxFilter,
|
||||
type InboxItem,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -51,52 +52,54 @@ export function InboxListPane({
|
||||
<section
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60",
|
||||
showRightDivider &&
|
||||
"after:pointer-events-none after:absolute after:bottom-0 after:right-0 after:top-10 after:z-40 after:w-px after:bg-border/35 after:content-['']",
|
||||
showRightDivider && topChromeInset.verticalDivider,
|
||||
)}
|
||||
>
|
||||
<TopChromeBackdrop className="h-[76px]" />
|
||||
<div className="absolute inset-x-0 top-[42px] z-40 min-h-[32px] px-5 py-[4px]">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
<Inbox className="h-[14px] w-[14px] shrink-0 text-muted-foreground" />
|
||||
<h2 className="translate-y-px truncate text-sm font-semibold leading-5 tracking-tight">
|
||||
Inbox
|
||||
</h2>
|
||||
<TopChromeInsetHeader>
|
||||
<div className="px-5 py-1">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
<Inbox className="h-[14px] w-[14px] shrink-0 text-muted-foreground" />
|
||||
<h2 className="translate-y-px truncate text-sm font-semibold leading-5 tracking-tight">
|
||||
Inbox
|
||||
</h2>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border-border/70 bg-background/70 px-2.5 text-[11px] font-medium leading-none text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/60 hover:text-foreground"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<span>{activeFilter?.label ?? "All"}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) =>
|
||||
onFilterChange(value as InboxFilter)
|
||||
}
|
||||
value={filter}
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border-border/70 bg-background/70 px-2.5 text-[11px] font-medium leading-[1] text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/60 hover:text-foreground"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<span>{activeFilter?.label ?? "All"}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[10rem]">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => onFilterChange(value as InboxFilter)}
|
||||
value={filter}
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</TopChromeInsetHeader>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pt-[76px]"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||
data-testid="home-inbox-list"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
@@ -168,7 +171,7 @@ export function InboxListPane({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 line-clamp-2 text-sm leading-5 [&_*]:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
||||
"mt-0.5 line-clamp-2 text-sm leading-5 **:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
||||
isDone
|
||||
? "font-normal text-muted-foreground"
|
||||
: "font-semibold text-foreground",
|
||||
|
||||
@@ -250,18 +250,15 @@ export function HuddleIndicator({
|
||||
<>
|
||||
<Button
|
||||
aria-label="Start huddle"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5",
|
||||
className,
|
||||
)}
|
||||
className={className}
|
||||
data-testid="channel-start-huddle-trigger"
|
||||
disabled={startDisabled || isStarting}
|
||||
onClick={() => headphonesGate.gate(() => onStart())}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<Headphones className="size-5" />
|
||||
<Headphones />
|
||||
</Button>
|
||||
{gateDialog}
|
||||
</>
|
||||
@@ -313,17 +310,14 @@ export function HuddleIndicator({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Join active huddle (${participantCount} participant${participantCount !== 1 ? "s" : ""})`}
|
||||
className={cn(
|
||||
"relative h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5",
|
||||
className,
|
||||
)}
|
||||
className={cn("relative", className)}
|
||||
disabled={isJoining || isStarting}
|
||||
onClick={() => headphonesGate.gate(() => void doJoin())}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
>
|
||||
<Headphones className="size-5 text-muted-foreground" />
|
||||
<Headphones />
|
||||
<span className="absolute inset-0 animate-pulse rounded-lg ring-2 ring-border/70" />
|
||||
{/* Participant count badge */}
|
||||
{participantCount > 0 && (
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
<SmilePlus />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Editor } from "@tiptap/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
@@ -84,26 +83,18 @@ export const MessageComposerToolbar = React.memo(
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
aria-label="Toggle formatting"
|
||||
aria-pressed={isFormattingOpen}
|
||||
disabled={composerDisabled}
|
||||
onClick={() => onFormattingToggle(!isFormattingOpen)}
|
||||
onMouseDown={onCaptureSelection}
|
||||
className={cn(
|
||||
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
|
||||
"hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
isFormattingOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent text-muted-foreground",
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isFormattingOpen ? "default" : "ghost"}
|
||||
>
|
||||
<ALargeSmall className="h-4 w-4" />
|
||||
</button>
|
||||
<ALargeSmall />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Formatting</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -125,9 +116,9 @@ export const MessageComposerToolbar = React.memo(
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close formatting</TooltipContent>
|
||||
@@ -171,7 +162,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
<AtSign />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mention someone</TooltipContent>
|
||||
@@ -187,11 +178,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
{isUploading ? <Spinner /> : <Paperclip />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Attach image</TooltipContent>
|
||||
@@ -211,26 +198,18 @@ export const MessageComposerToolbar = React.memo(
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
aria-label="Toggle formatting"
|
||||
aria-pressed={isFormattingOpen}
|
||||
disabled={composerDisabled}
|
||||
onClick={() => onFormattingToggle(!isFormattingOpen)}
|
||||
onMouseDown={onCaptureSelection}
|
||||
className={cn(
|
||||
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
|
||||
"hover:bg-muted hover:text-foreground",
|
||||
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
isFormattingOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-transparent text-muted-foreground",
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isFormattingOpen ? "default" : "ghost"}
|
||||
>
|
||||
<ALargeSmall className="h-4 w-4" />
|
||||
</button>
|
||||
<ALargeSmall />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Formatting</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -256,7 +235,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
||||
/>
|
||||
) : (
|
||||
<ArrowUp aria-hidden className="h-4 w-4" />
|
||||
<ArrowUp aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@ 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 {
|
||||
AuxiliaryPanelHeader,
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
auxiliaryPanelContentPaddingClass,
|
||||
} from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
OverlayPanelBackdrop,
|
||||
@@ -26,12 +32,12 @@ import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
|
||||
type MessageThreadPanelProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
canResetWidth: boolean;
|
||||
channel: Channel | null;
|
||||
channelId: string | null;
|
||||
channelName: string;
|
||||
currentPubkey?: string;
|
||||
disabled?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
editTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
@@ -49,8 +55,6 @@ type MessageThreadPanelProps = {
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onExpandReplies: (message: TimelineMessage) => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onScrollTargetResolved: () => void;
|
||||
onSelectReplyTarget: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
@@ -89,12 +93,12 @@ function canManageMessage(
|
||||
|
||||
export function MessageThreadPanel({
|
||||
agentPubkeys,
|
||||
canResetWidth,
|
||||
channel,
|
||||
channelId,
|
||||
channelName,
|
||||
currentPubkey,
|
||||
disabled = false,
|
||||
layout = "standalone",
|
||||
editTarget,
|
||||
isSending,
|
||||
isSinglePanelView = false,
|
||||
@@ -109,8 +113,6 @@ export function MessageThreadPanel({
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onExpandReplies,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
onScrollTargetResolved,
|
||||
onSelectReplyTarget,
|
||||
onSend,
|
||||
@@ -129,7 +131,7 @@ export function MessageThreadPanel({
|
||||
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const isFloatingOverlay = isOverlay && !isSinglePanelView;
|
||||
const usesChannelSplitChrome = !isOverlay && !isSinglePanelView;
|
||||
const isSplitLayout = layout === "split";
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
useComposerHeightPadding(
|
||||
threadBodyRef,
|
||||
@@ -173,6 +175,220 @@ export function MessageThreadPanel({
|
||||
return null;
|
||||
}
|
||||
|
||||
const threadScrollRegion = (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
|
||||
isSplitLayout && auxiliaryPanelContentPaddingClass,
|
||||
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
|
||||
)}
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(threadHead, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
onFollowThread ? (_msg) => onFollowThread() : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
|
||||
{threadReplies.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{threadReplies.map((entry) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
entry.summary &&
|
||||
"group/message -mx-1 rounded-2xl px-1 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
key={entry.message.renderKey ?? entry.message.id}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
hoverBackground={!entry.summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(entry.message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const threadFooter = (
|
||||
<>
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-20 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
|
||||
data-testid="thread-scroll-to-latest"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDown aria-hidden />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
|
||||
ref={threadComposerWrapperRef}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<MessageComposer
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channel?.channelType ?? null}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
|
||||
onEditLastOwnMessage={onEditLastOwnMessage}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={`Reply in thread to ${threadHead.author}`}
|
||||
profiles={profiles}
|
||||
replyTarget={composerReplyTarget}
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
/>
|
||||
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
|
||||
{toolbarExtraActions ? (
|
||||
<div className="shrink-0">{toolbarExtraActions}</div>
|
||||
) : null}
|
||||
{threadTypingPubkeys.length > 0 ? (
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
className="min-w-0 flex-1 px-0 py-0"
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={threadTypingPubkeys}
|
||||
variant="activity"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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}
|
||||
<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>{threadHeaderContent}</AuxiliaryPanelHeader>
|
||||
{threadScrollRegion}
|
||||
{threadFooter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
@@ -189,251 +405,20 @@ export function MessageThreadPanel({
|
||||
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
|
||||
}}
|
||||
>
|
||||
{!isOverlay && !isSinglePanelView && (
|
||||
<button
|
||||
aria-label="Resize thread panel"
|
||||
className="peer/thread-resize group/thread-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
|
||||
data-testid="message-thread-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/thread-resize:bg-border/80 group-focus-visible/thread-resize:bg-border/80" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isOverlay ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-transparent after:absolute after:bottom-0 after:-left-px after:top-10 after:w-px after:bg-border/45 after:transition-colors peer-hover/thread-resize:after:bg-border/80 peer-focus-visible/thread-resize:after:bg-border/80",
|
||||
usesChannelSplitChrome ? "h-[92px]" : "h-[76px]",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center",
|
||||
isSinglePanelView
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[76px] min-h-[76px] shrink-0 gap-[10px] bg-background/80 pb-[3px] pl-[16px] pr-[8px] pt-[43px] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pr-[12px] dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
|
||||
: isOverlay
|
||||
? "relative z-50 min-h-[44px] shrink-0 gap-3 bg-background/80 px-3 py-[6px] 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-[48px] z-50 h-[32px] gap-[10px] py-0 pl-[16px] pr-[8px] sm:pr-[12px]",
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-background/80 pb-[0.1875rem] pl-4 pr-2 pt-[2.6875rem] 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-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 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
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
isSinglePanelView ? "gap-[4px]" : "gap-1.5",
|
||||
)}
|
||||
>
|
||||
{isSinglePanelView ? (
|
||||
<div className="relative h-[14px] w-[14px] shrink-0">
|
||||
<Button
|
||||
aria-label="Back to conversation"
|
||||
className="absolute left-1/2 top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="message-thread-back"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="translate-y-px text-base font-semibold leading-6 tracking-tight">
|
||||
Thread
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
className="ml-auto h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
data-testid="message-thread-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
{threadHeaderContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
|
||||
usesChannelSplitChrome ? "pt-[92px]" : isOverlay ? "" : "pt-[76px]",
|
||||
)}
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(threadHead, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
onFollowThread ? (_msg) => onFollowThread() : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-3 pb-3 pt-1"
|
||||
data-testid="message-thread-replies"
|
||||
>
|
||||
{threadReplies.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{threadReplies.map((entry) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
entry.summary &&
|
||||
"group/message -mx-1 rounded-2xl px-1 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
key={entry.message.renderKey ?? entry.message.id}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
hoverBackground={!entry.summary}
|
||||
layoutVariant="thread-reply"
|
||||
message={entry.message}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{entry.summary ? (
|
||||
<MessageThreadSummaryRow
|
||||
depth={entry.message.depth}
|
||||
message={entry.message}
|
||||
onOpenThread={onExpandReplies}
|
||||
summary={entry.summary}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground/80">
|
||||
No replies in this branch yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reply in the thread to continue this branch.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-20 flex justify-center px-4">
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
|
||||
data-testid="thread-scroll-to-latest"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDown aria-hidden />
|
||||
{newMessageCount > 0
|
||||
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
|
||||
: "Jump to latest"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
|
||||
ref={threadComposerWrapperRef}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<MessageComposer
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channel?.channelType ?? null}
|
||||
disabled={disabled || isSending || !channelId}
|
||||
draftKey={`thread:${threadHead.id}`}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
|
||||
onEditLastOwnMessage={onEditLastOwnMessage}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={`Reply in thread to ${threadHead.author}`}
|
||||
profiles={profiles}
|
||||
replyTarget={composerReplyTarget}
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
/>
|
||||
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center gap-2">
|
||||
{toolbarExtraActions ? (
|
||||
<div className="shrink-0">{toolbarExtraActions}</div>
|
||||
) : null}
|
||||
{threadTypingPubkeys.length > 0 ? (
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
className="min-w-0 flex-1 px-0 py-0"
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={threadTypingPubkeys}
|
||||
variant="activity"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{threadScrollRegion}
|
||||
{threadFooter}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
@@ -174,7 +175,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-2 pt-[92px]",
|
||||
"flex w-full flex-col gap-2",
|
||||
channelChrome.contentPadding,
|
||||
(showDirectMessageIntro || showGenericEmpty) && "min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { observeElementBlockSize } from "@/shared/layout/observeElementBlockSize";
|
||||
|
||||
/**
|
||||
* Observes the height of the composer overlay and sets the scroll
|
||||
* container's `paddingBottom` to match, so content is never hidden
|
||||
@@ -18,7 +20,7 @@ export function useComposerHeightPadding(
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
const composerEl = composerRef.current;
|
||||
|
||||
if (!scrollEl || !composerEl || typeof ResizeObserver === "undefined") {
|
||||
if (!scrollEl || !composerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,18 +54,10 @@ export function useComposerHeightPadding(
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const height =
|
||||
entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
|
||||
applyPadding(height);
|
||||
});
|
||||
|
||||
observer.observe(composerEl);
|
||||
applyPadding(composerEl.getBoundingClientRect().height);
|
||||
const disconnect = observeElementBlockSize(composerEl, applyPadding);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
// Reset to a sensible default when unmounting
|
||||
disconnect();
|
||||
scrollEl.style.paddingBottom = "";
|
||||
};
|
||||
}, [scrollContainerRef, composerRef, resetKey]);
|
||||
|
||||
@@ -31,6 +31,12 @@ import { useAgentSession } from "@/shared/context/AgentSessionContext";
|
||||
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
||||
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
||||
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import {
|
||||
AuxiliaryPanelHeader,
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
auxiliaryPanelContentPaddingClass,
|
||||
} from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -42,14 +48,22 @@ import {
|
||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||
|
||||
type UserProfilePanelProps = {
|
||||
canResetWidth: boolean;
|
||||
canResetWidth?: boolean;
|
||||
currentPubkey?: string;
|
||||
isSinglePanelView?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
onClose: () => void;
|
||||
onOpenDm?: (pubkeys: string[]) => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onResetWidth?: () => void;
|
||||
onResizeStart?: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
pubkey: string;
|
||||
/**
|
||||
* When true, the panel sits beside a sibling pane managed by a single-panel
|
||||
* width controller (ChannelScreen). The width is clamped so the sibling keeps
|
||||
* at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse)
|
||||
* have no such sibling, so they omit this and use the configured width
|
||||
* directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel.
|
||||
*/
|
||||
splitPaneClamp?: boolean;
|
||||
widthPx: number;
|
||||
};
|
||||
@@ -112,6 +126,7 @@ export function UserProfilePanel({
|
||||
canResetWidth,
|
||||
currentPubkey,
|
||||
isSinglePanelView = false,
|
||||
layout = "standalone",
|
||||
onClose,
|
||||
onOpenDm,
|
||||
onResetWidth,
|
||||
@@ -122,8 +137,7 @@ export function UserProfilePanel({
|
||||
}: UserProfilePanelProps) {
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const isFloatingOverlay = isOverlay && !isSinglePanelView;
|
||||
const usesChannelSplitChrome =
|
||||
splitPaneClamp && !isOverlay && !isSinglePanelView;
|
||||
const isSplitLayout = layout === "split";
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
|
||||
const [view, setView] = React.useState<ProfilePanelView>("summary");
|
||||
@@ -233,6 +247,122 @@ export function UserProfilePanel({
|
||||
? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length
|
||||
: undefined;
|
||||
|
||||
const headerLeftContent = (
|
||||
<AuxiliaryPanelHeaderGroup>
|
||||
{view !== "summary" ? (
|
||||
<Button
|
||||
aria-label="Back to profile"
|
||||
className="shrink-0"
|
||||
data-testid="user-profile-panel-back"
|
||||
onClick={() => setView("summary")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<AuxiliaryPanelTitle>{panelTitle}</AuxiliaryPanelTitle>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{view === "memories" && isOwner === true ? (
|
||||
<MemoryRefreshButton agentPubkey={pubkey} variant="outline" />
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close profile"
|
||||
data-testid="user-profile-panel-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const profileBody = (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-4 pb-6",
|
||||
isSplitLayout && auxiliaryPanelContentPaddingClass,
|
||||
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
|
||||
)}
|
||||
>
|
||||
{view === "summary" ? (
|
||||
<ProfileSummaryView
|
||||
canEditAgent={canEditAgent}
|
||||
canViewActivity={canViewActivity}
|
||||
channelCount={profileChannels.length}
|
||||
channelsLoading={channelsQuery.isLoading}
|
||||
displayName={displayName}
|
||||
followMutation={followMutation}
|
||||
handleEditAgent={handleEditAgent}
|
||||
handleMessage={handleMessage}
|
||||
handleOpenActivity={handleOpenActivity}
|
||||
isBot={isBot}
|
||||
isFollowing={isFollowing}
|
||||
isOwner={isOwner}
|
||||
isSelf={isSelf}
|
||||
managedAgent={managedAgent}
|
||||
memoriesLoading={memoryQuery.isLoading}
|
||||
memoryCount={memoryCount}
|
||||
ownerDisplayName={ownerDisplayName}
|
||||
ownerHandle={ownerHandle}
|
||||
onOpenChannels={() => setView("channels")}
|
||||
onOpenMemories={() => setView("memories")}
|
||||
onOpenDm={onOpenDm}
|
||||
presenceLoaded={presenceQuery.isSuccess}
|
||||
presenceStatus={presenceStatus}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
relayAgent={relayAgent}
|
||||
unfollowMutation={unfollowMutation}
|
||||
userStatus={userStatus}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "memories" ? (
|
||||
<MemoryFocusedView agentPubkey={pubkey} isOwner={isOwner} />
|
||||
) : null}
|
||||
|
||||
{view === "channels" ? (
|
||||
<ChannelsFocusedView
|
||||
channels={profileChannels}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const editAgentDialog =
|
||||
canEditAgent && managedAgent ? (
|
||||
<EditAgentDialog
|
||||
agent={managedAgent}
|
||||
onOpenChange={setEditAgentOpen}
|
||||
open={editAgentOpen}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (isSplitLayout) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<AuxiliaryPanelHeader>
|
||||
{headerLeftContent}
|
||||
{headerActions}
|
||||
</AuxiliaryPanelHeader>
|
||||
{profileBody}
|
||||
</div>
|
||||
{editAgentDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
@@ -251,7 +381,7 @@ export function UserProfilePanel({
|
||||
: `${widthPx}px`,
|
||||
}}
|
||||
>
|
||||
{!isOverlay && !isSinglePanelView && (
|
||||
{!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"
|
||||
@@ -272,10 +402,7 @@ export function UserProfilePanel({
|
||||
{!isOverlay ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-background/80 backdrop-blur-md after:absolute after:left-0 after:right-0 after:top-10 after:h-px after:bg-border/35 supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55",
|
||||
usesChannelSplitChrome ? "h-[92px]" : "h-[76px]",
|
||||
)}
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[4.75rem] bg-background/80 backdrop-blur-md after:absolute after:left-0 after:right-0 after:top-10 after:h-px after:bg-border/35 supports-[backdrop-filter]:bg-background/70 peer-hover/profile-resize:after:bg-border/80 peer-focus-visible/profile-resize:after:bg-border/80 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -283,146 +410,20 @@ export function UserProfilePanel({
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center",
|
||||
isSinglePanelView
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[76px] min-h-[76px] shrink-0 gap-[10px] bg-transparent pb-[4px] pl-[16px] pr-[8px] pt-[42px] sm:pl-[24px] sm:pr-[12px]`
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-transparent pb-1 pl-4 pr-2 pt-[2.625rem] sm:pl-6 sm:pr-3`
|
||||
: isOverlay
|
||||
? "relative z-50 min-h-[44px] shrink-0 gap-3 bg-background/80 px-3 py-[6px] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55"
|
||||
: cn(
|
||||
"absolute inset-x-0 z-50 bg-transparent 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",
|
||||
usesChannelSplitChrome
|
||||
? "top-[48px] h-[32px] gap-[10px] py-0 pl-[16px] pr-[8px] sm:pr-[12px]"
|
||||
: "top-[42px] min-h-[32px] gap-3 px-3 py-[4px]",
|
||||
),
|
||||
? "relative z-50 min-h-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 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-[2.625rem] z-50 min-h-8 gap-3 bg-transparent px-3 py-1 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
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{view !== "summary" ? (
|
||||
<Button
|
||||
aria-label="Back to profile"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
usesChannelSplitChrome
|
||||
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
|
||||
: "h-7 w-7 rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
data-testid="user-profile-panel-back"
|
||||
onClick={() => setView("summary")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft
|
||||
className={cn(usesChannelSplitChrome ? "size-4" : "size-3.5")}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<h2
|
||||
className={cn(
|
||||
"translate-y-px font-semibold tracking-tight",
|
||||
usesChannelSplitChrome
|
||||
? "text-base leading-6"
|
||||
: "text-sm leading-5",
|
||||
)}
|
||||
>
|
||||
{panelTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{view === "memories" && isOwner === true ? (
|
||||
<MemoryRefreshButton
|
||||
agentPubkey={pubkey}
|
||||
className={cn(
|
||||
usesChannelSplitChrome
|
||||
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
|
||||
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
iconClassName={cn(
|
||||
usesChannelSplitChrome ? "size-4" : "h-2.5 w-2.5",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close profile"
|
||||
className={cn(
|
||||
usesChannelSplitChrome
|
||||
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
data-testid="user-profile-panel-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X
|
||||
className={cn(
|
||||
usesChannelSplitChrome ? "size-5" : "h-2.5 w-2.5",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{headerLeftContent}
|
||||
{headerActions}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto px-4 pb-6",
|
||||
!isFloatingOverlay &&
|
||||
(usesChannelSplitChrome ? "pt-[92px]" : "pt-[76px]"),
|
||||
)}
|
||||
>
|
||||
{view === "summary" ? (
|
||||
<ProfileSummaryView
|
||||
canEditAgent={canEditAgent}
|
||||
canViewActivity={canViewActivity}
|
||||
channelCount={profileChannels.length}
|
||||
channelsLoading={channelsQuery.isLoading}
|
||||
displayName={displayName}
|
||||
followMutation={followMutation}
|
||||
handleEditAgent={handleEditAgent}
|
||||
handleMessage={handleMessage}
|
||||
handleOpenActivity={handleOpenActivity}
|
||||
isBot={isBot}
|
||||
isFollowing={isFollowing}
|
||||
isOwner={isOwner}
|
||||
isSelf={isSelf}
|
||||
managedAgent={managedAgent}
|
||||
memoriesLoading={memoryQuery.isLoading}
|
||||
memoryCount={memoryCount}
|
||||
ownerDisplayName={ownerDisplayName}
|
||||
ownerHandle={ownerHandle}
|
||||
onOpenChannels={() => setView("channels")}
|
||||
onOpenMemories={() => setView("memories")}
|
||||
onOpenDm={onOpenDm}
|
||||
presenceLoaded={presenceQuery.isSuccess}
|
||||
presenceStatus={presenceStatus}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
relayAgent={relayAgent}
|
||||
unfollowMutation={unfollowMutation}
|
||||
userStatus={userStatus}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "memories" ? (
|
||||
<MemoryFocusedView agentPubkey={pubkey} isOwner={isOwner} />
|
||||
) : null}
|
||||
|
||||
{view === "channels" ? (
|
||||
<ChannelsFocusedView
|
||||
channels={profileChannels}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{profileBody}
|
||||
</aside>
|
||||
{canEditAgent && managedAgent ? (
|
||||
<EditAgentDialog
|
||||
agent={managedAgent}
|
||||
onOpenChange={setEditAgentOpen}
|
||||
open={editAgentOpen}
|
||||
/>
|
||||
) : null}
|
||||
{editAgentDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useUpdaterContext } from "./hooks/UpdaterProvider";
|
||||
import type { UpdateStatus } from "./hooks/use-updater";
|
||||
|
||||
const indicatorButtonClass =
|
||||
"relative h-8 w-8 text-muted-foreground/80 hover:bg-muted/60 hover:text-foreground";
|
||||
|
||||
const iconClass = "h-4 w-4";
|
||||
"relative text-muted-foreground/80 hover:bg-muted/60 hover:text-foreground";
|
||||
|
||||
const variants: Record<
|
||||
"available" | "downloading" | "installing" | "ready",
|
||||
@@ -17,32 +15,30 @@ const variants: Record<
|
||||
Icon: typeof RefreshCcw;
|
||||
label: string;
|
||||
badgeColor: string;
|
||||
iconClass: string;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
available: {
|
||||
Icon: RefreshCcw,
|
||||
label: "Update available",
|
||||
badgeColor: "bg-primary",
|
||||
iconClass: iconClass,
|
||||
},
|
||||
downloading: {
|
||||
Icon: Loader2,
|
||||
label: "Downloading update\u2026",
|
||||
badgeColor: "bg-primary",
|
||||
iconClass: `${iconClass} animate-spin`,
|
||||
spin: true,
|
||||
},
|
||||
installing: {
|
||||
Icon: Loader2,
|
||||
label: "Installing update\u2026",
|
||||
badgeColor: "bg-primary",
|
||||
iconClass: `${iconClass} animate-spin`,
|
||||
spin: true,
|
||||
},
|
||||
ready: {
|
||||
Icon: RotateCw,
|
||||
label: "Restart to update",
|
||||
badgeColor: "bg-emerald-500",
|
||||
iconClass: iconClass,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,7 +62,7 @@ export function UpdateIndicator({ className }: { className?: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { Icon, label, badgeColor, iconClass: variantIconClass } = variant;
|
||||
const { Icon, label, badgeColor, spin } = variant;
|
||||
const isActionable = status.state === "available" || status.state === "ready";
|
||||
const handleClick =
|
||||
status.state === "ready"
|
||||
@@ -91,7 +87,7 @@ export function UpdateIndicator({ className }: { className?: string }) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className={variantIconClass} />
|
||||
<Icon className={spin ? "animate-spin" : undefined} />
|
||||
<span
|
||||
className={`absolute right-1 top-1 h-1.5 w-1.5 rounded-full ${badgeColor} animate-pulse`}
|
||||
/>
|
||||
|
||||
69
desktop/src/shared/layout/AuxiliaryPanelHeader.tsx
Normal file
69
desktop/src/shared/layout/AuxiliaryPanelHeader.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type AuxiliaryPanelHeaderProps = React.ComponentProps<"div">;
|
||||
type AuxiliaryPanelHeaderGroupProps = React.ComponentProps<"div">;
|
||||
type AuxiliaryPanelTitleProps = React.ComponentProps<"h2">;
|
||||
|
||||
/** Compact title/action row for right auxiliary panels in split layouts. */
|
||||
export function AuxiliaryPanelHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AuxiliaryPanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none relative z-30 bg-background/80 backdrop-blur-md after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border/35 after:content-[''] supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
|
||||
topChromeInset.padding,
|
||||
channelChrome.negativeMargin,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative z-30 flex min-h-8 shrink-0 cursor-default select-none items-center gap-2.5 px-4 py-1.5 sm:pr-3"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const auxiliaryPanelContentPaddingClass = channelChrome.contentPadding;
|
||||
|
||||
export function AuxiliaryPanelHeaderGroup({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AuxiliaryPanelHeaderGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-1 items-center gap-1.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuxiliaryPanelTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AuxiliaryPanelTitleProps) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
27
desktop/src/shared/layout/MainInsetContext.tsx
Normal file
27
desktop/src/shared/layout/MainInsetContext.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MainInsetContext =
|
||||
React.createContext<React.RefObject<HTMLElement | null> | null>(null);
|
||||
|
||||
export function MainInsetProvider({
|
||||
children,
|
||||
mainInsetRef,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mainInsetRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
return (
|
||||
<MainInsetContext.Provider value={mainInsetRef}>
|
||||
{children}
|
||||
</MainInsetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ref to the app `<main>` inset element where shared chrome CSS vars live. */
|
||||
export function useMainInsetRef() {
|
||||
const ref = React.useContext(MainInsetContext);
|
||||
if (!ref) {
|
||||
throw new Error("useMainInsetRef must be used within MainInsetProvider");
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
30
desktop/src/shared/layout/TopChromeInsetHeader.tsx
Normal file
30
desktop/src/shared/layout/TopChromeInsetHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type TopChromeInsetHeaderProps = React.ComponentProps<"div">;
|
||||
|
||||
/**
|
||||
* Flowed header row that clears the global search/drag chrome and draws the
|
||||
* horizontal separator at the bottom edge of that inset.
|
||||
*/
|
||||
export function TopChromeInsetHeader({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TopChromeInsetHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
topChromeInset.headerBase,
|
||||
topChromeInset.padding,
|
||||
topChromeInset.divider,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
desktop/src/shared/layout/chromeLayout.ts
Normal file
56
desktop/src/shared/layout/chromeLayout.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const TOP_CHROME_HEIGHT_DEFAULT = "2.5rem";
|
||||
export const CHANNEL_CONTENT_TOP_PADDING_DEFAULT = "5.75rem";
|
||||
|
||||
export const chromeCssVars = {
|
||||
topChromeHeight: "--sprout-top-chrome-height",
|
||||
channelContentTopPadding: "--sprout-channel-content-top-padding",
|
||||
} as const;
|
||||
|
||||
export const chromeCssVarDefaults = {
|
||||
[chromeCssVars.topChromeHeight]: TOP_CHROME_HEIGHT_DEFAULT,
|
||||
[chromeCssVars.channelContentTopPadding]: CHANNEL_CONTENT_TOP_PADDING_DEFAULT,
|
||||
} as const;
|
||||
|
||||
export const channelContentTopPaddingMeasurement = {
|
||||
cssVariable: chromeCssVars.channelContentTopPadding,
|
||||
resetValue: chromeCssVarDefaults[chromeCssVars.channelContentTopPadding],
|
||||
} as const;
|
||||
|
||||
/** Tailwind class fragments for layout under the global top chrome. */
|
||||
export const topChromeInset = {
|
||||
/** Absolute/fixed top offset below the search bar. */
|
||||
top: "top-(--sprout-top-chrome-height,2.5rem)",
|
||||
/** Padding-top clearing the global top chrome. */
|
||||
padding: "pt-(--sprout-top-chrome-height,2.5rem)",
|
||||
/** `after:` pseudo-element top offset. */
|
||||
afterTop: "after:top-(--sprout-top-chrome-height,2.5rem)",
|
||||
/** Horizontal divider at the bottom edge of the global top chrome inset. */
|
||||
divider:
|
||||
"before:pointer-events-none before:absolute before:inset-x-0 before:top-(--sprout-top-chrome-height,2.5rem) before:h-px before:bg-border/35 before:content-['']",
|
||||
/** Shared header backdrop and bottom border below the inset row. */
|
||||
headerBase:
|
||||
"relative z-40 shrink-0 border-b border-border/35 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",
|
||||
/** Vertical pane divider starting below the global top chrome. */
|
||||
verticalDivider:
|
||||
"after:pointer-events-none after:absolute after:bottom-0 after:right-0 after:top-(--sprout-top-chrome-height,2.5rem) after:z-40 after:w-px after:bg-border/35 after:content-['']",
|
||||
} as const;
|
||||
|
||||
/** Tailwind class fragments for the global top chrome backdrop strip. */
|
||||
export const topChromeBackdrop = {
|
||||
/** Height matching the global top chrome search/drag strip. */
|
||||
height: "h-(--sprout-top-chrome-height,2.5rem)",
|
||||
/** `after:` pseudo-element offset aligned to the bottom of top chrome. */
|
||||
dividerTop: "after:top-(--sprout-top-chrome-height,2.5rem)",
|
||||
} as const;
|
||||
|
||||
/** Tailwind class fragments for measured channel header chrome. */
|
||||
export const channelChrome = {
|
||||
/** Padding-top that clears the measured channel header chrome. */
|
||||
contentPadding: "pt-(--sprout-channel-content-top-padding,5.75rem)",
|
||||
/** Absolute/fixed top offset below the measured channel header chrome. */
|
||||
top: "top-(--sprout-channel-content-top-padding,5.75rem)",
|
||||
/** Height matching the measured channel header chrome. */
|
||||
headerHeight: "h-(--sprout-channel-content-top-padding,5.75rem)",
|
||||
/** Negative margin for overlaid channel chrome that should not affect flow. */
|
||||
negativeMargin: "-mb-(--sprout-channel-content-top-padding,5.75rem)",
|
||||
} as const;
|
||||
31
desktop/src/shared/layout/observeElementBlockSize.ts
Normal file
31
desktop/src/shared/layout/observeElementBlockSize.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Reads the block size from a ResizeObserver entry, preferring border-box size
|
||||
* when available.
|
||||
*/
|
||||
export function readElementBlockSize(entry: ResizeObserverEntry): number {
|
||||
return entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes `onSize` immediately and whenever the observed element's block size
|
||||
* changes. Returns a cleanup function that disconnects the observer.
|
||||
*/
|
||||
export function observeElementBlockSize(
|
||||
element: HTMLElement,
|
||||
onSize: (size: number) => void,
|
||||
): () => void {
|
||||
onSize(element.getBoundingClientRect().height);
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
onSize(readElementBlockSize(entry));
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
65
desktop/src/shared/layout/useMeasuredCssVariable.ts
Normal file
65
desktop/src/shared/layout/useMeasuredCssVariable.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { observeElementBlockSize } from "./observeElementBlockSize";
|
||||
|
||||
type UseMeasuredCssVariableArgs = {
|
||||
cssVariable: string;
|
||||
enabled?: boolean;
|
||||
resetKey?: unknown;
|
||||
resetValue: string;
|
||||
targetRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Observes an element's block size and writes it as a CSS custom property on a
|
||||
* target element. Uses `useLayoutEffect` so the first measurement happens
|
||||
* before paint.
|
||||
*
|
||||
* Returns a callback ref for the source element. Attach it instead of a ref
|
||||
* object so the measurement re-runs when the source mounts later than this
|
||||
* hook's owner — e.g. inside a lazy-loaded subtree.
|
||||
*/
|
||||
export function useMeasuredCssVariable({
|
||||
targetRef,
|
||||
cssVariable,
|
||||
resetValue,
|
||||
resetKey,
|
||||
enabled = true,
|
||||
}: UseMeasuredCssVariableArgs): React.RefCallback<HTMLElement> {
|
||||
const [sourceEl, setSourceEl] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
void resetKey;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEl = targetRef.current;
|
||||
|
||||
if (!sourceEl || !targetEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastValue: number | null = null;
|
||||
|
||||
const applySize = (height: number) => {
|
||||
const px = Math.ceil(height);
|
||||
if (lastValue !== null && Math.abs(px - lastValue) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastValue = px;
|
||||
targetEl.style.setProperty(cssVariable, `${px}px`);
|
||||
};
|
||||
|
||||
const disconnect = observeElementBlockSize(sourceEl, applySize);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
targetEl.style.setProperty(cssVariable, resetValue);
|
||||
};
|
||||
}, [sourceEl, targetRef, cssVariable, resetValue, resetKey, enabled]);
|
||||
|
||||
return setSourceEl;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
|
||||
|
||||
/**
|
||||
* Blurred strip pinned to the top of a scroll container so content scrolls
|
||||
@@ -9,16 +10,17 @@ import { cn } from "@/shared/lib/cn";
|
||||
* pointer-events-none) and sits at z-40 — below the global chrome controls
|
||||
* (z-[45]) but above page content.
|
||||
*
|
||||
* Pass the height via `className` (e.g. `h-10` for pages with no sub-header,
|
||||
* `h-[76px]` for panels whose own header occupies the top). `cn`/twMerge lets
|
||||
* the passed height override the default.
|
||||
* Height follows `--sprout-top-chrome-height` on the app `<main>` inset.
|
||||
* Pass `className` to override for panels whose own header occupies the top.
|
||||
*/
|
||||
export function TopChromeBackdrop({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 h-10 bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 after:absolute after:inset-x-0 after:top-10 after:h-px after:bg-border/35 after:content-[''] dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
|
||||
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-background/75 backdrop-blur-md supports-[backdrop-filter]:bg-background/65 after:absolute after:inset-x-0 after:h-px after:bg-border/35 after:content-[''] dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
|
||||
topChromeBackdrop.height,
|
||||
topChromeBackdrop.dividerTop,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Card } from "@/shared/ui/card";
|
||||
import { useSidebar } from "@/shared/ui/sidebar";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||
|
||||
type ViewLoadingFallbackKind =
|
||||
| "agents"
|
||||
@@ -20,25 +22,26 @@ function LoadingHeaderSkeleton() {
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-[44px] min-w-0 cursor-default select-none items-center gap-[10px] bg-background/70 py-[6px] pl-[16px] pr-[8px] shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl transition-[padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)] sm:pl-[24px] sm:pr-[12px]",
|
||||
sidebarState === "collapsed" && "md:pl-[160px]",
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded-xs" />
|
||||
<Skeleton className="h-4 w-28 max-w-[50vw]" />
|
||||
<TopChromeInsetHeader data-tauri-drag-region>
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-[44px] min-w-0 cursor-default select-none items-center gap-[10px] py-[6px] pl-[16px] pr-[8px] transition-[padding] duration-200 ease-linear sm:pl-[24px] sm:pr-[12px]",
|
||||
sidebarState === "collapsed" && "md:pl-[160px]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-[6px]">
|
||||
<Skeleton className="h-3.5 w-3.5 rounded-xs" />
|
||||
<Skeleton className="h-4 w-28 max-w-[50vw]" />
|
||||
</div>
|
||||
<Skeleton className="mt-2 h-4 w-full max-w-2xl" />
|
||||
</div>
|
||||
<Skeleton className="mt-2 h-4 w-full max-w-2xl" />
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
||||
</div>
|
||||
</header>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
||||
</div>
|
||||
</header>
|
||||
</TopChromeInsetHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ function MessageRowsSkeleton() {
|
||||
|
||||
function AgentsLoadingBody() {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-14 sm:px-6">
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -139,7 +142,7 @@ function AgentsLoadingBody() {
|
||||
|
||||
function CardListLoadingBody() {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-14 sm:px-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-28" />
|
||||
@@ -176,11 +179,18 @@ function CardListLoadingBody() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelLoadingBody() {
|
||||
function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-6">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-1 sm:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-4",
|
||||
// The real channel header overlays content, so reserve its
|
||||
// measured height — unless an in-flow header skeleton is above.
|
||||
hasHeader ? "pt-3" : channelChrome.contentPadding,
|
||||
)}
|
||||
>
|
||||
<MessageRowsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,9 +209,14 @@ function ChannelLoadingBody() {
|
||||
);
|
||||
}
|
||||
|
||||
function ForumLoadingBody() {
|
||||
function ForumLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
!hasHeader && channelChrome.contentPadding,
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-border/60 p-4">
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
</div>
|
||||
@@ -241,9 +256,13 @@ export function ViewLoadingFallback({
|
||||
{kind === "agents" ? <AgentsLoadingBody /> : null}
|
||||
{kind === "workflows" ? <CardListLoadingBody /> : null}
|
||||
{kind === "projects" ? <CardListLoadingBody /> : null}
|
||||
{kind === "channel" ? <ChannelLoadingBody /> : null}
|
||||
{kind === "forum" ? <ForumLoadingBody /> : null}
|
||||
{kind === "pulse" ? <ChannelLoadingBody /> : null}
|
||||
{kind === "channel" ? (
|
||||
<ChannelLoadingBody hasHeader={includeHeader} />
|
||||
) : null}
|
||||
{kind === "forum" ? <ForumLoadingBody hasHeader={includeHeader} /> : null}
|
||||
{kind === "pulse" ? (
|
||||
<ChannelLoadingBody hasHeader={includeHeader} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4.5 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -13,8 +13,7 @@ const buttonVariants = cva(
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
outline: "border border-input/40 bg-background hover:bg-muted/70",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
@@ -22,9 +21,9 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-10 px-8",
|
||||
icon: "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-9 w-full rounded-lg border border-input/40 bg-background px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -403,7 +403,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
@@ -593,7 +593,7 @@ const SidebarRail = React.forwardRef<
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-20 w-full rounded-lg border border-input/40 bg-background px-3 py-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -5,13 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
outline: "border border-input/40 bg-background hover:bg-muted/70",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3 min-w-9",
|
||||
|
||||
@@ -675,7 +675,9 @@ test("thread panel width uses session storage and reset handle", async ({
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
const rootMessage = timeline.getByTestId("message-row").first();
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
const resizeHandle = threadPanel.getByTestId("message-thread-resize-handle");
|
||||
const resizeHandle = threadPanel.getByTestId(
|
||||
"right-auxiliary-pane-resize-handle",
|
||||
);
|
||||
|
||||
await rootMessage.hover();
|
||||
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
||||
|
||||
144
desktop/tests/helpers/zoom-shot.mjs
Normal file
144
desktop/tests/helpers/zoom-shot.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
// Custom screenshot helper for header zoom-fix comparison.
|
||||
// Supports --port (4173 baseline / 4174 branch), --zoom (CSS zoom factor), and --open-thread / --open-profile / --open-agent.
|
||||
import { chromium } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values: a } = parseArgs({
|
||||
options: {
|
||||
name: { type: "string", default: "shot" },
|
||||
port: { type: "string", default: "4173" },
|
||||
channel: { type: "string", default: "general" },
|
||||
zoom: { type: "string", default: "1" },
|
||||
"open-thread": { type: "boolean", default: false },
|
||||
"open-profile": { type: "boolean", default: false },
|
||||
"open-agent": { type: "boolean", default: false },
|
||||
viewport: { type: "string", default: "1280x800" },
|
||||
clip: { type: "string" },
|
||||
wait: { type: "string", default: "1500" },
|
||||
outdir: { type: "string", default: "/tmp/zoom-shots" },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const [vw, vh] = a.viewport.split("x").map(Number);
|
||||
mkdirSync(resolve(a.outdir), { recursive: true });
|
||||
|
||||
const BASE = `http://127.0.0.1:${a.port}`;
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const ONBOARDING_PREFIX = "sprout-onboarding-complete.v1:";
|
||||
const TEST_PUBKEYS = [
|
||||
DEFAULT_MOCK_PUBKEY,
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: vw, height: vh } });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const id = "e2e-default-workspace";
|
||||
const ws = {
|
||||
id,
|
||||
name: "E2E Test",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem("sprout-workspaces", JSON.stringify([ws]));
|
||||
window.localStorage.setItem("sprout-active-workspace-id", id);
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ prefix, pubkeys }) => {
|
||||
for (const pk of pubkeys)
|
||||
window.localStorage.setItem(`${prefix}${pk}`, "true");
|
||||
},
|
||||
{ prefix: ONBOARDING_PREFIX, pubkeys: TEST_PUBKEYS },
|
||||
);
|
||||
await page.addInitScript(() => {
|
||||
class MockNotification extends EventTarget {
|
||||
static permission = "granted";
|
||||
static async requestPermission() {
|
||||
return "granted";
|
||||
}
|
||||
constructor(t, o) {
|
||||
super();
|
||||
this.title = t;
|
||||
this.body = o?.body ?? null;
|
||||
this.onclick = null;
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
window.__SPROUT_E2E__ = { mode: "mock" };
|
||||
window.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(BASE);
|
||||
await page.waitForSelector(`[data-testid="channel-${a.channel}"]`, {
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.click(`[data-testid="channel-${a.channel}"]`);
|
||||
await page.waitForSelector(`[data-testid="chat-title"]`, { timeout: 10000 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
if (a["open-profile"]) {
|
||||
// Click chat-title or members trigger to open profile? Better: avatar in chat header.
|
||||
const avatar = page
|
||||
.locator('[data-testid="chat-header"] [data-testid^="avatar-"]')
|
||||
.first();
|
||||
if ((await avatar.count()) > 0) await avatar.click();
|
||||
}
|
||||
|
||||
if (a["open-thread"]) {
|
||||
// Send a message via mock so we have something to thread.
|
||||
await page.evaluate(
|
||||
({ ch }) => {
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: ch,
|
||||
content: "Header spacing comparison anchor message",
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
});
|
||||
},
|
||||
{ ch: a.channel },
|
||||
);
|
||||
await page.waitForTimeout(400);
|
||||
// Hover the message and click its reply trigger.
|
||||
const replyBtn = page.locator('[data-testid^="reply-message-"]').first();
|
||||
if ((await replyBtn.count()) > 0) {
|
||||
await replyBtn.scrollIntoViewIfNeeded();
|
||||
await replyBtn.click({ force: true });
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply CSS zoom on the html element AFTER interaction setup.
|
||||
const zoom = Number(a.zoom);
|
||||
if (zoom !== 1) {
|
||||
await page.evaluate((z) => {
|
||||
document.documentElement.style.zoom = String(z);
|
||||
}, zoom);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(Number(a.wait));
|
||||
|
||||
const filepath = join(resolve(a.outdir), `${a.name}.png`);
|
||||
const opts = {};
|
||||
if (a.clip) {
|
||||
const [x, y, w, h] = a.clip.split(",").map(Number);
|
||||
opts.clip = { x, y, width: w, height: h };
|
||||
}
|
||||
await page.screenshot({ path: filepath, ...opts });
|
||||
console.log(filepath);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user