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:
Taylor Ho
2026-06-11 07:27:23 -07:00
committed by GitHub
parent 8c8312932a
commit 1ca16c898c
40 changed files with 1498 additions and 1038 deletions

View File

@@ -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>
</>
)}

View File

@@ -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 : (

View File

@@ -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(

View File

@@ -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>
</>
);

View File

@@ -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>
);

View File

@@ -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>
);
});

View File

@@ -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={

View File

@@ -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={

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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,
);
}

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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",

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
);

View File

@@ -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}

View File

@@ -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]);

View File

@@ -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}
</>
);
}

View File

@@ -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`}
/>

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;

View 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();
};
}

View 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;
}

View File

@@ -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,
)}
/>

View File

@@ -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>
);
}

View File

@@ -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: {

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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",

View File

@@ -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();

View 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();
}