Fix desktop right-side panel chrome overlap (#806)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-06-02 10:37:36 -04:00
committed by GitHub
parent ede8ddb425
commit 5bed17a117
36 changed files with 1026 additions and 574 deletions

View File

@@ -23,7 +23,7 @@ function GlobalTopDivider() {
return (
<div
aria-hidden="true"
className="pointer-events-none fixed right-0 top-10 z-50 h-px bg-border/35"
className="pointer-events-none fixed right-0 top-10 z-40 h-px bg-border/35"
style={{ left: state === "expanded" ? "var(--sidebar-width)" : 0 }}
/>
);
@@ -48,7 +48,7 @@ export function AppTopChrome({
data-tauri-drag-region
/>
<GlobalTopDivider />
<div className="fixed left-[80px] top-[9px] z-[80] flex items-center gap-0.5">
<div className="fixed left-[80px] top-[9px] z-[45] flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
@@ -75,7 +75,7 @@ export function AppTopChrome({
</div>
<TopbarSearch
channels={channels}
className="fixed left-1/2 top-[7px] z-[80] hidden w-[300px] max-w-[34vw] -translate-x-1/2 md:block lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
currentPubkey={currentPubkey}
focusRequest={searchFocusRequest}
onOpenChannel={onOpenChannel}

View File

@@ -1,8 +1,13 @@
import * as React from "react";
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { TranscriptItem } from "./agentSessionTypes";
import { ToolItem } from "./AgentSessionToolItem";
import { formatTranscriptTime } from "./agentSessionUtils";
@@ -12,10 +17,12 @@ export function AgentSessionTranscriptList({
agentName,
emptyDescription,
items,
profiles,
}: {
agentName: string;
emptyDescription: string;
items: TranscriptItem[];
profiles?: UserProfileLookup;
}) {
if (items.length === 0) {
return (
@@ -31,12 +38,16 @@ export function AgentSessionTranscriptList({
<div
aria-label="Live ACP transcript"
aria-live="polite"
className="mx-auto w-full max-w-3xl py-1"
className="w-full py-1"
role="log"
>
{items.map((item) => (
<div className="mt-4 first:mt-0" key={item.id}>
<TranscriptItemView agentName={agentName} item={item} />
<TranscriptItemView
agentName={agentName}
item={item}
profiles={profiles}
/>
</div>
))}
</div>
@@ -46,12 +57,16 @@ export function AgentSessionTranscriptList({
const TranscriptItemView = React.memo(function TranscriptItemView({
agentName,
item,
profiles,
}: {
agentName: string;
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
if (item.type === "message") {
return <MessageItem agentName={agentName} item={item} />;
return (
<MessageItem agentName={agentName} item={item} profiles={profiles} />
);
}
if (item.type === "tool") {
return <ToolItem item={item} />;
@@ -68,24 +83,44 @@ const TranscriptItemView = React.memo(function TranscriptItemView({
function MessageItem({
agentName,
item,
profiles,
}: {
agentName: string;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
return (
<div
className={cn(
"flex px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
isAssistant ? "flex-row" : "ml-auto flex-row-reverse",
"flex flex-row px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
)}
data-role={isAssistant ? "assistant-message" : "user-message"}
>
{!isAssistant ? (
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="mr-2 mt-1 h-5 w-5 shrink-0 rounded-full text-[8px]"
displayName={authorLabel}
size="xs"
/>
) : null}
<div
className={cn(
"group relative min-w-0 flex flex-col gap-1",
isAssistant ? "w-full items-start" : "max-w-[85%] items-end",
"group relative min-w-0 flex flex-col items-start gap-1",
isAssistant ? "w-full" : "max-w-[85%]",
)}
>
{isAssistant ? (
@@ -182,7 +217,7 @@ function LifecycleItem({
return (
<div
className={cn(
"flex items-center justify-center gap-1.5 px-4 py-2 text-center text-xs",
"flex items-center justify-start gap-1.5 px-1 py-2 text-left text-xs",
isError ? "text-destructive" : "text-muted-foreground",
)}
>

View File

@@ -9,6 +9,7 @@ import {
} from "lucide-react";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
@@ -30,6 +31,7 @@ type ManagedAgentSessionPanelProps = {
emptyDescription?: string;
showHeader?: boolean;
showRaw?: boolean;
profiles?: UserProfileLookup;
};
export function ManagedAgentSessionPanel({
@@ -39,6 +41,7 @@ export function ManagedAgentSessionPanel({
emptyDescription = "Mention this agent in a channel to watch the next turn.",
showHeader = true,
showRaw = true,
profiles,
}: ManagedAgentSessionPanelProps) {
const hasObserver = isManagedAgentActive(agent);
const { connectionState, errorMessage, events } = useObserverEvents(
@@ -96,6 +99,7 @@ export function ManagedAgentSessionPanel({
errorMessage={errorMessage}
events={scopedEvents}
hasObserver={hasObserver}
profiles={profiles}
showRaw={showRaw}
transcript={scopedTranscript}
/>
@@ -145,6 +149,7 @@ function SessionBody({
errorMessage,
events,
hasObserver,
profiles,
showRaw,
transcript,
}: {
@@ -154,6 +159,7 @@ function SessionBody({
errorMessage: string | null;
events: ObserverEvent[];
hasObserver: boolean;
profiles?: UserProfileLookup;
showRaw: boolean;
transcript: TranscriptItem[];
}) {
@@ -175,6 +181,7 @@ function SessionBody({
agentName={agentName}
emptyDescription={emptyDescription}
items={transcript}
profiles={profiles}
/>
{showRaw ? <RawEventRail events={events} /> : null}
</div>

View File

@@ -116,6 +116,7 @@ function upsertMessage(
text: string,
timestamp: string,
channelId: string | null,
authorPubkey: string | null = null,
) {
const currentKey = d.activeMessageKey.get(id);
@@ -126,6 +127,7 @@ function upsertMessage(
...existing,
text: existing.text + text,
channelId,
authorPubkey: authorPubkey ?? existing.authorPubkey,
});
return;
}
@@ -141,6 +143,7 @@ function upsertMessage(
text,
timestamp,
channelId,
authorPubkey,
});
d.activeMessageKey = new Map(d.activeMessageKey);
d.activeMessageKey.set(id, newKey);
@@ -318,6 +321,7 @@ export function processTranscriptEvent(
parsedPrompt.userText,
event.timestamp,
channelId,
parsedPrompt.userPubkey,
);
}
if (parsedPrompt.sections.length > 0) {

View File

@@ -17,10 +17,16 @@ export function parsePromptText(text: string): {
sections: PromptSection[];
userText: string;
userTitle: string;
userPubkey: string | null;
} {
const sections = parsePromptSections(text);
if (sections.length === 0) {
return { sections: [], userText: text.trim(), userTitle: "Prompt" };
return {
sections: [],
userText: text.trim(),
userTitle: "Prompt",
userPubkey: null,
};
}
const eventSection = sections.find((section) =>
@@ -29,12 +35,16 @@ export function parsePromptText(text: string): {
const eventContent = eventSection
? extractEventContent(eventSection.body)
: "";
const eventAuthorPubkey = eventSection
? extractEventAuthorPubkey(eventSection.body)
: null;
const eventKind = eventSection?.title.split(":").slice(1).join(":").trim();
return {
sections,
userText: eventContent,
userTitle: eventKind ? titleCase(eventKind) : "Sprout event",
userPubkey: eventAuthorPubkey,
};
}
@@ -79,6 +89,11 @@ function extractEventContent(body: string): string {
return contentMatch?.[1]?.trim() ?? "";
}
function extractEventAuthorPubkey(body: string): string | null {
const fromMatch = body.match(/^From:.*\bhex:\s*([0-9a-fA-F]{64})/m);
return fromMatch?.[1]?.toLowerCase() ?? null;
}
export function extractContentText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(extractBlockText).join("\n");

View File

@@ -28,6 +28,7 @@ export type TranscriptItem =
title: string;
text: string;
timestamp: string;
authorPubkey?: string | null;
channelId?: string | null;
}
| {

View File

@@ -1,5 +1,5 @@
import type * as React from "react";
import { Activity, Bot, CircleDot, Octagon, X } from "lucide-react";
import { CircleDot, Octagon, X } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -12,11 +12,15 @@ import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
@@ -26,6 +30,8 @@ type AgentSessionThreadPanelProps = {
channel: Channel;
canInterruptTurn: boolean;
isWorking: boolean;
isSinglePanelView?: boolean;
profiles?: UserProfileLookup;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
@@ -38,14 +44,18 @@ export function AgentSessionThreadPanel({
canInterruptTurn,
channel,
isWorking,
isSinglePanelView = false,
profiles,
onClose,
onResetWidth,
onResizeStart,
widthPx,
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const avatarUrl = profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
const isFloatingOverlay = isOverlay && !isSinglePanelView;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
@@ -66,13 +76,21 @@ export function AgentSessionThreadPanel({
return (
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
className={cn(
PANEL_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="agent-session-thread-panel"
style={{ width: `${widthPx}px` }}
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
{!isOverlay && (
{!isOverlay && !isSinglePanelView && (
<button
aria-label="Resize agent session panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
@@ -93,76 +111,92 @@ export function AgentSessionThreadPanel({
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/75 backdrop-blur-md after:absolute after:bottom-0 after:left-0 after:top-10 after:w-px after:bg-border/80 supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/75 backdrop-blur-md before:absolute before:left-0 before:right-0 before:top-10 before:h-px before:bg-border/35 after:absolute after:bottom-0 after:-left-px after:top-10 after:w-px after:bg-border/80 supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35"
/>
) : null}
<div
className={cn(
"z-50 flex cursor-default select-none items-center gap-3 px-4",
isOverlay
? "relative min-h-[44px] shrink-0 border-b border-border/70 bg-background/80 py-2.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-11 min-h-[32px] py-[4px]",
"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-[4px] pl-[16px] pr-[8px] pt-[42px] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pl-[24px] 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-[42px] z-50 min-h-[32px] gap-3 px-3 py-[4px]",
)}
data-tauri-drag-region
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold tracking-tight">
{agent.name}
</h2>
<div className="flex items-center gap-1.5">
<Activity className="h-3 w-3 text-muted-foreground" />
<p className="truncate text-xs text-muted-foreground">
Agent activity log
</p>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
isSinglePanelView && "translate-y-px",
)}
>
<UserAvatar
avatarUrl={avatarUrl}
className="h-5 w-5 shrink-0 rounded-full text-[8px]"
displayName={agent.name}
size="xs"
/>
<h2 className="min-w-0 flex-1 translate-y-px truncate text-sm font-semibold leading-5 tracking-tight">
{agent.name}
</h2>
</div>
<div
className={cn(
"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="h-6 w-6 text-foreground hover:bg-muted/60 hover:text-foreground"
data-testid="agent-session-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isLive ? (
<Badge className="shrink-0 gap-1" variant="default">
<CircleDot className="h-3 w-3" />
Live
</Badge>
) : (
<Badge className="shrink-0" variant="secondary">
Idle
</Badge>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Stop current agent turn"
data-testid="agent-session-stop-turn"
disabled={!canInterruptTurn || !isLive || !isWorking}
onClick={() => {
void handleInterruptTurn();
}}
size="sm"
type="button"
variant="outline"
>
<Octagon className="h-3.5 w-3.5" />
Stop
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isWorking
? canInterruptTurn
? "Interrupt the current ACP turn without stopping the agent process."
: "This agent cannot be interrupted from this workspace."
: "No active turn to interrupt."}
</TooltipContent>
</Tooltip>
<Button
aria-label="Close activity panel"
data-testid="agent-session-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div
@@ -178,6 +212,7 @@ export function AgentSessionThreadPanel({
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}
/>

View File

@@ -162,7 +162,7 @@ export function BotActivityComposerAction({
className={cn(
"inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary",
isInline
? "h-7 min-w-0 gap-2 border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent"
? "h-7 min-w-0 gap-2 overflow-visible border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent"
: "h-9 min-w-9 gap-1.5 px-2 text-xs",
)}
data-testid="bot-activity-composer-trigger"
@@ -176,7 +176,7 @@ export function BotActivityComposerAction({
onMouseLeave={closeWithDelay}
type="button"
>
<span className="flex -space-x-1">
<span className="flex items-center overflow-visible py-px -space-x-1">
{typingAgents.slice(0, 2).map((agent) => (
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}

View File

@@ -29,6 +29,8 @@ import {
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import {
AlertDialog,
AlertDialogAction,
@@ -111,6 +113,7 @@ export function ChannelManagementSheet({
onOpenChange,
open,
}: ChannelManagementSheetProps) {
const { isDark } = useTheme();
const channelId = channel?.id ?? null;
const detailsQuery = useChannelDetailsQuery(channelId, open);
const membersQuery = useChannelMembersQuery(channelId, open);
@@ -220,11 +223,23 @@ export function ChannelManagementSheet({
return (
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
className={cn(
"flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 p-0 shadow-none sm:max-w-xl",
isDark
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
: "bg-background",
)}
data-testid="channel-management-sheet"
side="right"
>
<SheetHeader className="relative z-10 space-y-4 bg-background/25 px-6 py-6 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<SheetHeader
className={cn(
"relative z-10 space-y-4 px-6 py-6 text-left shadow-none",
isDark
? "bg-background/60 backdrop-blur-xl supports-[backdrop-filter]:bg-background/50"
: "bg-background",
)}
>
<SheetTitle className="pr-8">{channel.name}</SheetTitle>
<SheetDescription className="sr-only">
Channel settings
@@ -251,7 +266,7 @@ export function ChannelManagementSheet({
</div>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
<div className="flex-1 space-y-6 overflow-y-auto bg-background px-6 py-6">
<ChannelIdRow channelId={resolvedChannel.id} />
{detailsQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">

View File

@@ -17,7 +17,6 @@ import {
} from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import { Button } from "@/shared/ui/button";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
@@ -41,9 +40,11 @@ type ChannelPaneProps = {
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSinglePanelView?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
canResetThreadPanelWidth: boolean;
onCancelEdit?: () => void;
onCancelThreadReply: () => void;
onCloseAgentSession: () => void;
@@ -58,6 +59,7 @@ type ChannelPaneProps = {
onOpenAgentSession: (pubkey: string) => void;
onOpenDm?: (pubkeys: string[]) => void;
onOpenThread: (message: TimelineMessage) => void;
onResetThreadPanelWidth: () => void;
onSelectThreadReplyTarget: (message: TimelineMessage) => void;
onSendMessage: (
content: string,
@@ -76,6 +78,9 @@ type ChannelPaneProps = {
remove: boolean,
) => Promise<void>;
onThreadScrollTargetResolved: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
@@ -84,6 +89,7 @@ type ChannelPaneProps = {
profilePanelPubkey?: string | null;
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadPanelWidthPx: number;
threadTypingPubkeys: string[];
threadReplyTargetId: string | null;
threadReplyTargetMessage: TimelineMessage | null;
@@ -113,9 +119,11 @@ export const ChannelPane = React.memo(function ChannelPane({
isFollowingThread,
isFollowingThreadById,
isJoining = false,
isSinglePanelView = false,
isSending,
isTimelineLoading,
messages,
canResetThreadPanelWidth,
onCancelEdit,
onCancelThreadReply,
onCloseAgentSession,
@@ -131,10 +139,12 @@ export const ChannelPane = React.memo(function ChannelPane({
onOpenAgentSession,
onOpenDm,
onOpenThread,
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onSendThreadReply,
onThreadScrollTargetResolved,
onThreadPanelResizeStart,
onTargetReached,
onToggleReaction,
onUnfollowThread,
@@ -147,22 +157,20 @@ export const ChannelPane = React.memo(function ChannelPane({
targetMessageId,
threadHeadMessage,
threadMessages,
threadPanelWidthPx,
threadScrollTargetId,
threadTypingPubkeys,
threadReplyTargetId,
threadReplyTargetMessage,
typingPubkeys,
}: ChannelPaneProps) {
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
onResizeStart: handleThreadPanelResizeStart,
widthPx: threadPanelWidthPx,
} = useThreadPanelWidth();
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
useComposerHeightPadding(timelineScrollRef, composerWrapperRef);
useComposerHeightPadding(
timelineScrollRef,
composerWrapperRef,
isSinglePanelView,
);
// Scope the edit target to the correct composer: if the message being edited
// lives inside the open thread (thread head or a reply), show the editing UI
@@ -282,144 +290,144 @@ export const ChannelPane = React.memo(function ChannelPane({
: null,
[agentSessionAgents, openAgentSessionPubkey],
);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{channelFind.isOpen ? (
<ChannelFindBar
matchCount={channelFind.matchCount}
matchIndex={channelFind.activeIndex}
onClose={channelFind.close}
onNext={channelFind.goToNext}
onPrevious={channelFind.goToPrevious}
onQueryChange={channelFind.setQuery}
query={channelFind.query}
{!isSinglePanelView ? (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{channelFind.isOpen ? (
<ChannelFindBar
matchCount={channelFind.matchCount}
matchIndex={channelFind.activeIndex}
onClose={channelFind.close}
onNext={channelFind.goToNext}
onPrevious={channelFind.goToPrevious}
onQueryChange={channelFind.setQuery}
query={channelFind.query}
/>
) : null}
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
) : null}
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
{isNonMemberView ? (
<div
data-testid="join-banner"
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-4 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
#{activeChannel?.name}
</span>
</span>
</div>
<Button
disabled={isJoining}
onClick={() => {
void onJoinChannel?.();
}}
size="sm"
variant="default"
{isNonMemberView ? (
<div
data-testid="join-banner"
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-4 py-3"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={mainEditTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6 -mt-1">
<div className="flex h-full w-full items-center gap-2">
{hasComposerBotActivity ? (
<div className="shrink-0">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
#{activeChannel?.name}
</span>
</span>
</div>
<Button
disabled={isJoining}
onClick={() => {
void onJoinChannel?.();
}}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={mainEditTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
<div className="h-7 overflow-visible bg-background px-4 pb-1 pt-0 sm:px-6">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="shrink-0 overflow-visible">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
variant="inline"
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 px-0 py-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
variant="inline"
typingPubkeys={typingPubkeys}
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 px-0 py-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
variant="activity"
/>
) : null}
) : null}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
) : null}
{threadHeadMessage ? (
<MessageThreadPanel
@@ -431,6 +439,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={threadEditTarget}
isFollowingThread={isFollowingThread}
isSending={isSending}
isSinglePanelView={isSinglePanelView}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
@@ -451,8 +460,8 @@ export const ChannelPane = React.memo(function ChannelPane({
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
canResetWidth={canResetThreadPanelWidth}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
threadHead={threadHeadMessage}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
@@ -481,19 +490,22 @@ export const ChannelPane = React.memo(function ChannelPane({
(entry) =>
entry.pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
)}
isSinglePanelView={isSinglePanelView}
profiles={profiles}
onClose={onCloseAgentSession}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
widthPx={threadPanelWidthPx}
/>
) : profilePanelPubkey ? (
<UserProfilePanel
canResetWidth={canResetThreadPanelWidth}
currentPubkey={currentPubkey}
isSinglePanelView={isSinglePanelView}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
pubkey={profilePanelPubkey}
widthPx={threadPanelWidthPx}
/>

View File

@@ -40,17 +40,16 @@ import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAnces
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type {
Channel,
Identity,
Profile,
RelayEvent,
RespondToMode,
} from "@/shared/api/types";
import type { RespondToMode } from "@/shared/api/types";
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 { useElementWidthBreakpoint } from "@/shared/hooks/use-mobile";
import {
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
useThreadPanelWidth,
} from "@/shared/hooks/useThreadPanelWidth";
import {
mergeAgentNamesIntoProfiles,
useChannelActivityTyping,
@@ -58,18 +57,7 @@ import {
import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
type ChannelScreenProps = {
activeChannel: Channel | null;
currentIdentity?: Identity;
currentProfile?: Profile;
onCloseForumPost: () => void;
onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null;
targetForumReplyId: string | null;
targetMessageEvents: RelayEvent[];
targetMessageId: string | null;
};
import type { ChannelScreenProps } from "./ChannelScreen.types";
export function ChannelScreen({
activeChannel,
currentIdentity,
@@ -93,7 +81,17 @@ export function ChannelScreen({
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
string | null
>(null);
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
onResizeStart: handleThreadPanelResizeStart,
widthPx: threadPanelWidthPx,
} = useThreadPanelWidth();
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [channelContentRef, isNarrowPanelViewport] =
useElementWidthBreakpoint<HTMLDivElement>(
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
);
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null,
);
@@ -120,12 +118,10 @@ export function ChannelScreen({
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: (activeChannel?.lastMessageAt ?? null);
React.useEffect(() => {
if (!activeChannelId || activeChannel?.isMember === false) {
return;
}
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
const {
@@ -141,7 +137,6 @@ export function ChannelScreen({
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
if (!activeChannel || targetMessageEvents.length === 0) {
@@ -269,15 +264,12 @@ export function ChannelScreen({
(messageId: string) => {
const descendantIds: string[] = [];
const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
while (pendingIds.length > 0) {
const currentId = pendingIds.pop();
if (!currentId) continue;
descendantIds.push(currentId);
pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
}
return descendantIds;
},
[directReplyIdsByParentId],
@@ -300,13 +292,11 @@ export function ChannelScreen({
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages],
);
const {
handleCancelEdit,
handleCancelThreadReply,
@@ -337,7 +327,6 @@ export function ChannelScreen({
threadReplyTargetId,
toggleReactionMutation,
});
const effectiveToggleReaction = React.useMemo(
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
@@ -345,7 +334,6 @@ export function ChannelScreen({
: undefined,
[activeChannel, handleToggleReaction],
);
const handleMarkUnread = React.useCallback(
(message: TimelineMessage) => {
if (!activeChannelId) return;
@@ -354,7 +342,6 @@ export function ChannelScreen({
},
[activeChannelId, markChannelUnread],
);
const {
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
@@ -373,7 +360,6 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } =
useChannelProfilePanel({
closeAgentSession: handleCloseAgentSession,
@@ -383,7 +369,6 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const isTimelineLoading =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
@@ -449,6 +434,12 @@ export function ChannelScreen({
]);
useLoadMissingAncestors(activeChannel, resolvedMessages);
const isSinglePanelView =
isNarrowPanelViewport &&
activeChannel?.channelType !== "forum" &&
Boolean(
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
);
return (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
@@ -463,9 +454,13 @@ export function ChannelScreen({
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">
<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" />}>
@@ -486,6 +481,7 @@ export function ChannelScreen({
botTypingEntries={botTypingEntries}
channelFind={channelFind}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
@@ -506,6 +502,7 @@ export function ChannelScreen({
isFollowingThreadById={isFollowingThread}
isFollowingThread={isNotifiedForCurrentThread}
isSending={sendMessageMutation.isPending}
isSinglePanelView={isSinglePanelView}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
@@ -533,6 +530,7 @@ export function ChannelScreen({
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenDm={handleOpenDm}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
@@ -541,6 +539,7 @@ export function ChannelScreen({
onThreadScrollTargetResolved={
handleThreadScrollTargetResolved
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
@@ -550,6 +549,7 @@ export function ChannelScreen({
targetMessageId={mainTimelineTargetMessageId}
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}

View File

@@ -0,0 +1,18 @@
import type {
Channel,
Identity,
Profile,
RelayEvent,
} from "@/shared/api/types";
export type ChannelScreenProps = {
activeChannel: Channel | null;
currentIdentity?: Identity;
currentProfile?: Profile;
onCloseForumPost: () => void;
onSelectForumPost: (postId: string) => void;
selectedForumPostId: string | null;
targetForumReplyId: string | null;
targetMessageEvents: RelayEvent[];
targetMessageId: string | null;
};

View File

@@ -1,10 +1,12 @@
import { LogIn } from "lucide-react";
import { createPortal } from "react-dom";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
@@ -15,6 +17,7 @@ type ChannelScreenHeaderProps = {
activeDmPresenceStatus: PresenceStatus | null;
currentPubkey?: string;
isJoining?: boolean;
showHeaderContent?: boolean;
onJoinChannel?: () => Promise<void>;
onManageChannel: () => void;
onToggleMembers: () => void;
@@ -27,6 +30,7 @@ export function ChannelScreenHeader({
activeDmPresenceStatus,
currentPubkey,
isJoining = false,
showHeaderContent = true,
onJoinChannel,
onManageChannel,
onToggleMembers,
@@ -38,33 +42,47 @@ export function ChannelScreenHeader({
!activeChannel.archivedAt &&
onJoinChannel;
const actions = activeChannel ? (
showJoinButton ? (
<Button
disabled={isJoining}
onClick={() => void onJoinChannel()}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining…" : "Join"}
</Button>
) : (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers}
/>
)
) : null;
if (!showHeaderContent) {
if (typeof document === "undefined") {
return null;
}
return createPortal(
<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>,
document.body,
);
}
return (
<ChatHeader
actionsPlacement="top-right"
belowSystemChrome
density="compact"
actions={
activeChannel ? (
showJoinButton ? (
<Button
disabled={isJoining}
onClick={() => void onJoinChannel()}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining\u2026" : "Join"}
</Button>
) : (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers}
/>
)
) : null
}
actions={actions}
channelType={activeChannel?.channelType}
description={getChannelDescription(activeChannel)}
statusBadge={

View File

@@ -220,7 +220,7 @@ export function MembersSidebar({
data-testid="members-sidebar"
side="right"
>
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 text-left backdrop-blur-xl supports-[backdrop-filter]:bg-background/20">
<SheetTitle>Members</SheetTitle>
<SheetDescription>
People and bots in {channel.name}.

View File

@@ -92,7 +92,7 @@ export function ChatHeader({
}: ChatHeaderProps) {
const trimmedDescription = description?.trim() ?? "";
const topRightActions = (
<div className="fixed right-3 top-[9px] z-[70] flex shrink-0 items-center gap-1">
<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>

View File

@@ -0,0 +1,42 @@
import type { InboxFilter } from "@/features/home/lib/inbox";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { RelayEvent } from "@/shared/api/types";
export function matchesInboxFilter(
item: { categories: InboxFilter[] },
filter: InboxFilter,
) {
if (filter === "all") {
return true;
}
return item.categories.includes(filter);
}
export function getContextMessageDepth(
event: RelayEvent,
eventById: ReadonlyMap<string, RelayEvent>,
): number {
let depth = 0;
let parentId = getThreadReference(event.tags).parentId;
const seen = new Set<string>([event.id]);
while (parentId && eventById.has(parentId) && !seen.has(parentId)) {
depth += 1;
seen.add(parentId);
parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId;
}
return depth;
}
export function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (tag?.[0] === "e" && typeof tag[1] === "string") {
return tag[1];
}
}
return null;
}

View File

@@ -20,7 +20,7 @@ export function HomeChannelActions({
}
return createPortal(
<div className="fixed right-3 top-[9px] z-[70] flex shrink-0 items-center gap-1">
<div className="fixed right-3 top-[9px] z-[45] flex shrink-0 items-center gap-1">
<UpdateIndicator />
<ChannelMembersBar
channel={channel}

View File

@@ -10,10 +10,19 @@ import {
buildInboxItems,
formatInboxFullTimestamp,
} from "@/features/home/lib/inbox";
import {
getContextMessageDepth,
getReactionTargetId,
matchesInboxFilter,
} from "@/features/home/lib/inboxViewHelpers";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import { useResizableInboxListWidth } from "@/features/home/useResizableInboxListWidth";
import {
INBOX_COLUMN_MIN_WIDTH_PX,
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";
@@ -23,54 +32,16 @@ import {
useToggleReactionMutation,
} from "@/features/messages/hooks";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri";
import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types";
import type { HomeFeedResponse } from "@/shared/api/types";
import { KIND_REACTION } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { Button } from "@/shared/ui/button";
function matchesInboxFilter(
item: { categories: InboxFilter[] },
filter: InboxFilter,
) {
if (filter === "all") {
return true;
}
return item.categories.includes(filter);
}
function getContextMessageDepth(
event: RelayEvent,
eventById: ReadonlyMap<string, RelayEvent>,
): number {
let depth = 0;
let parentId = getThreadReference(event.tags).parentId;
const seen = new Set<string>([event.id]);
while (parentId && eventById.has(parentId) && !seen.has(parentId)) {
depth += 1;
seen.add(parentId);
parentId = getThreadReference(eventById.get(parentId)?.tags ?? []).parentId;
}
return depth;
}
function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
if (tag?.[0] === "e" && typeof tag[1] === "string") {
return tag[1];
}
}
return null;
}
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
@@ -92,6 +63,10 @@ export function HomeView({
onOpenContext,
onRefresh,
}: HomeViewProps) {
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
const isNarrowHomeViewport =
homeInboxWidthPx > 0 &&
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
const [filter, setFilter] = React.useState<InboxFilter>("all");
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(
null,
@@ -256,9 +231,11 @@ export function HomeView({
}
if (!filteredItems.some((item) => item.id === selectedItemId)) {
setSelectedItemId(filteredItems[0]?.id ?? null);
setSelectedItemId(
isNarrowHomeViewport ? null : (filteredItems[0]?.id ?? null),
);
}
}, [filteredItems, selectedItemId]);
}, [filteredItems, isNarrowHomeViewport, selectedItemId]);
React.useEffect(() => {
void selectedItemId;
@@ -323,6 +300,24 @@ export function HomeView({
selectedItem !== null &&
currentPubkey?.trim().toLowerCase() ===
selectedItem.item.pubkey.trim().toLowerCase();
const isSinglePanelDetailView =
isNarrowHomeViewport && selectedItemId !== null;
const showListPane = !isSinglePanelDetailView;
const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView;
const maxEffectiveInboxListWidthPx =
homeInboxWidthPx > 0
? Math.max(
INBOX_COLUMN_MIN_WIDTH_PX,
homeInboxWidthPx - INBOX_COLUMN_MIN_WIDTH_PX,
)
: undefined;
const effectiveInboxListWidthPx =
homeInboxWidthPx > 0
? Math.min(
inboxListWidthPx,
maxEffectiveInboxListWidthPx ?? inboxListWidthPx,
)
: inboxListWidthPx;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -332,35 +327,46 @@ export function HomeView({
onOpenChannel={onOpenChannel}
/>
<div
className="relative grid min-h-0 flex-1 w-full lg:grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
className={cn(
"relative grid min-h-0 w-full flex-1",
showListPane && showDetailPane
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
: "grid-cols-1",
)}
data-testid="home-inbox"
ref={homeInboxRef}
style={
{
"--home-inbox-list-width": `${inboxListWidthPx}px`,
"--home-inbox-list-width": `${effectiveInboxListWidthPx}px`,
} as React.CSSProperties
}
>
<InboxListPane
doneSet={effectiveDoneSet}
filter={filter}
items={filteredItems}
onFilterChange={setFilter}
onSelect={(itemId) => {
setSelectedItemId(itemId);
markItemRead(itemId);
}}
selectedId={selectedItemId}
/>
{showListPane ? (
<InboxListPane
doneSet={effectiveDoneSet}
filter={filter}
items={filteredItems}
onFilterChange={setFilter}
onSelect={(itemId) => {
setSelectedItemId(itemId);
markItemRead(itemId);
}}
selectedId={selectedItemId}
/>
) : null}
<button
aria-label="Resize inbox list"
className="group absolute inset-y-0 z-[60] hidden w-3 -translate-x-1/2 cursor-col-resize lg:block"
className={cn(
"group absolute inset-y-0 z-40 w-3 -translate-x-1/2 cursor-col-resize",
showListPane && showDetailPane ? "block" : "hidden",
)}
data-testid="home-inbox-list-resize-handle"
onDoubleClick={
canResetInboxListWidth ? handleInboxListWidthReset : undefined
}
onPointerDown={handleInboxListResizeStart}
style={{ left: `${inboxListWidthPx}px` }}
style={{ left: `${effectiveInboxListWidthPx}px` }}
title={
canResetInboxListWidth
? "Drag to resize. Double-click to reset width."
@@ -371,107 +377,121 @@ export function HomeView({
<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" />
</button>
<InboxDetailPane
canDelete={canDelete}
canOpenChannel={Boolean(
selectedItem?.item.channelId &&
availableChannelIds.has(selectedItem.item.channelId),
)}
canReply={canReply}
disabledReplyReason={disabledReplyReason}
isDone={selectedItem ? effectiveDoneSet.has(selectedItem.id) : false}
isDeletingMessage={isDeletingMessage}
isSendingReply={isSendingReply}
isThreadContextLoading={threadContext.isLoading}
item={selectedItem}
messages={contextMessages}
replies={selectedItemReplies}
contextChannelName={selectedChannel?.name ?? null}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;
{showDetailPane ? (
<InboxDetailPane
canDelete={canDelete}
canOpenChannel={Boolean(
selectedItem?.item.channelId &&
availableChannelIds.has(selectedItem.item.channelId),
)}
canReply={canReply}
contextChannelName={selectedChannel?.name ?? null}
disabledReplyReason={disabledReplyReason}
isDeletingMessage={isDeletingMessage}
isDone={
selectedItem ? effectiveDoneSet.has(selectedItem.id) : false
}
isSendingReply={isSendingReply}
isSinglePanelView={isSinglePanelDetailView}
isThreadContextLoading={threadContext.isLoading}
item={selectedItem}
messages={contextMessages}
onBack={
isSinglePanelDetailView
? () => {
setSelectedItemId(null);
}
: undefined
}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;
}
setIsDeletingMessage(true);
void deleteMessage(selectedItem.id)
.then(() => {
onRefresh();
})
.finally(() => {
setIsDeletingMessage(false);
});
}}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
mediaTags,
mentionPubkeys,
parentEventId,
}) => {
const channelId = selectedItem?.item.channelId;
if (!selectedItem || !channelId || !canReply) {
throw new Error("Replies are not available for this item.");
}
const itemToReply = selectedItem;
setIsSendingReply(true);
try {
const result = await sendChannelMessage(
channelId,
content,
parentEventId,
mediaTags,
mentionPubkeys,
);
const authorPubkey = currentPubkey ?? itemToReply.item.pubkey;
const reply: InboxReply = {
authorLabel: currentPubkey
? resolveUserLabel({
currentPubkey,
profiles: feedProfiles,
pubkey: authorPubkey,
})
: "You",
avatarUrl:
currentPubkey && feedProfiles
? (feedProfiles[currentPubkey.trim().toLowerCase()]
?.avatarUrl ?? null)
: null,
content,
depth: result.depth,
fullTimestampLabel: formatInboxFullTimestamp(result.createdAt),
id: result.eventId,
parentId: result.parentEventId,
rootId: result.rootEventId,
};
setLocalRepliesByItemId((current) => ({
...current,
[itemToReply.id]: [...(current[itemToReply.id] ?? []), reply],
}));
onRefresh();
} finally {
setIsSendingReply(false);
}
}}
onToggleReaction={
canReact
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
await channelMessagesQuery.refetch();
setIsDeletingMessage(true);
void deleteMessage(selectedItem.id)
.then(() => {
onRefresh();
}
: undefined
}
onToggleDone={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
})
.finally(() => {
setIsDeletingMessage(false);
});
}}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
mediaTags,
mentionPubkeys,
parentEventId,
}) => {
const channelId = selectedItem?.item.channelId;
if (!selectedItem || !channelId || !canReply) {
throw new Error("Replies are not available for this item.");
}
const itemToReply = selectedItem;
setIsSendingReply(true);
try {
const result = await sendChannelMessage(
channelId,
content,
parentEventId,
mediaTags,
mentionPubkeys,
);
const authorPubkey = currentPubkey ?? itemToReply.item.pubkey;
const reply: InboxReply = {
authorLabel: currentPubkey
? resolveUserLabel({
currentPubkey,
profiles: feedProfiles,
pubkey: authorPubkey,
})
: "You",
avatarUrl:
currentPubkey && feedProfiles
? (feedProfiles[currentPubkey.trim().toLowerCase()]
?.avatarUrl ?? null)
: null,
content,
depth: result.depth,
fullTimestampLabel: formatInboxFullTimestamp(
result.createdAt,
),
id: result.eventId,
parentId: result.parentEventId,
rootId: result.rootEventId,
};
setLocalRepliesByItemId((current) => ({
...current,
[itemToReply.id]: [...(current[itemToReply.id] ?? []), reply],
}));
onRefresh();
} finally {
setIsSendingReply(false);
}
}}
onToggleDone={() => {
if (selectedItem) {
handleToggleDone(selectedItem.id);
}
}}
onToggleReaction={
canReact
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
await channelMessagesQuery.refetch();
onRefresh();
}
: undefined
}
}}
/>
replies={selectedItemReplies}
/>
) : null}
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import {
ArrowLeft,
CheckCheck,
Hash,
Mail,
@@ -20,6 +21,7 @@ import {
} from "@/features/home/ui/InboxMessageRow";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -43,11 +45,13 @@ type InboxDetailPaneProps = {
isDone: boolean;
isDeletingMessage?: boolean;
isSendingReply?: boolean;
isSinglePanelView?: boolean;
isThreadContextLoading?: boolean;
item: InboxItem | null;
messages?: InboxContextMessage[];
replies?: InboxReply[];
contextChannelName?: string | null;
onBack?: () => void;
onDelete: () => void;
onOpenContext?: (channelId: string, messageId: string) => void;
onSendReply: (input: {
@@ -72,11 +76,13 @@ export function InboxDetailPane({
isDone,
isDeletingMessage = false,
isSendingReply = false,
isSinglePanelView = false,
isThreadContextLoading = false,
item,
messages = [],
replies = [],
contextChannelName = null,
onBack,
onDelete,
onOpenContext,
onSendReply,
@@ -216,44 +222,95 @@ export function InboxDetailPane({
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] 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"
/>
<div className="absolute inset-x-0 top-[38px] z-50 flex min-h-[32px] items-center justify-between gap-3 py-[4px] pl-6 pr-3">
<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 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 truncate">{contextLabel}</span>
</h2>
)}
</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
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>
) : (
<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"
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" />
</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>
)}
</div>
</div>
</TooltipProvider>
<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>
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-[76px]">
@@ -287,7 +344,7 @@ export function InboxDetailPane({
<MessageComposer
channelId={item.item.channelId}
channelName={item.channelLabel ?? "channel"}
containerClassName="px-6 pb-4 sm:px-6 [&>div]:max-w-none"
containerClassName="px-4 pb-4 sm:px-4 [&>div]:mx-auto [&>div]:max-w-4xl"
disabled={!canReply}
draftKey={`inbox-reply:${item.id}`}
isSending={isSendingReply}

View File

@@ -50,11 +50,11 @@ export function InboxListPane({
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] 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"
/>
<div className="absolute inset-x-0 top-[42px] z-50 min-h-[32px] px-5 py-[4px]">
<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="truncate text-sm font-semibold leading-5 tracking-tight">
<h2 className="translate-y-px truncate text-sm font-semibold leading-5 tracking-tight">
Inbox
</h2>
</div>
@@ -116,7 +116,7 @@ export function InboxListPane({
className={cn(
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
isSelected
? "border-l-primary bg-muted/30"
? "border-l-transparent bg-muted/30"
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
)}
data-testid={`home-inbox-item-${item.id}`}

View File

@@ -63,7 +63,7 @@ export function InboxMessageRow({
} = useReactionHandler(timelineMessage, onToggleReaction);
return (
<div className="relative px-6 py-2">
<div className="relative px-5 py-2">
{message.isSelected ? (
<div
aria-hidden="true"
@@ -77,7 +77,7 @@ export function InboxMessageRow({
) : null}
<article
className={cn(
"group/message relative flex items-start gap-2.5 px-2 py-1",
"group/message relative flex items-start gap-2.5 px-0 py-0",
!message.isSelected && "hover:bg-muted/20",
)}
data-testid={
@@ -105,29 +105,26 @@ export function InboxMessageRow({
</div>
) : null}
<UserAvatar
avatarUrl={message.avatarUrl}
className="!h-9 !w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
<div className="relative shrink-0">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-8 w-8 shrink-0 rounded-xl"
displayName={message.authorLabel}
size="md"
/>
</div>
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<p className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<p className="truncate text-sm font-semibold text-foreground">
{message.authorLabel}
</p>
<p className="shrink-0 text-xs font-normal leading-none tabular-nums text-muted-foreground/55">
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
{message.isSelected ? (
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-muted-foreground/70">
Inbox item
</span>
) : null}
</div>
<div className="mt-1">
<div className="mt-0.5">
<Markdown
className="max-w-full text-left text-sm text-foreground"
content={message.content}

View File

@@ -1,13 +1,14 @@
import * as React from "react";
const INBOX_LIST_DEFAULT_WIDTH_PX = 320;
const INBOX_LIST_MIN_WIDTH_PX = 260;
export const INBOX_COLUMN_MIN_WIDTH_PX = 300;
export const INBOX_SINGLE_COLUMN_BREAKPOINT_PX = INBOX_COLUMN_MIN_WIDTH_PX * 2;
const INBOX_LIST_MAX_WIDTH_PX = 520;
const INBOX_LIST_WIDTH_SESSION_KEY = "sprout.desktop.home-inbox-list-width";
function clampInboxListWidth(width: number): number {
return Math.max(
INBOX_LIST_MIN_WIDTH_PX,
INBOX_COLUMN_MIN_WIDTH_PX,
Math.min(INBOX_LIST_MAX_WIDTH_PX, width),
);
}

View File

@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="sticky top-11 z-[5] flex justify-center py-1"
className="sticky top-[76px] z-[5] flex justify-center py-1"
data-testid="message-timeline-day-divider"
data-day-label={label}
>

View File

@@ -315,12 +315,12 @@ export function MessageActionBar({
return (
<div
className={cn(
"max-w-36 overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"translate-y-0 opacity-100 sm:max-w-0 sm:border-0 sm:shadow-none sm:translate-y-1 sm:opacity-0",
"sm:group-hover/message:max-w-36 sm:group-hover/message:border sm:group-hover/message:border-border/70 sm:group-hover/message:shadow-xs sm:group-hover/message:translate-y-0 sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:max-w-36 sm:group-focus-within/message:border sm:group-focus-within/message:border-border/70 sm:group-focus-within/message:shadow-xs sm:group-focus-within/message:translate-y-0 sm:group-focus-within/message:opacity-100",
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-opacity duration-150 ease-out",
"opacity-100 sm:pointer-events-none sm:opacity-0",
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100",
isReplyingToMessage || isReactionPickerOpen || isDropdownOpen
? "sm:max-w-36 sm:border sm:border-border/70 sm:shadow-xs sm:translate-y-0 sm:opacity-100"
? "sm:pointer-events-auto sm:opacity-100"
: "",
)}
data-testid={`message-action-bar-${message.id}`}

View File

@@ -671,7 +671,7 @@ export function MessageComposer({
/>
<div className="relative flex w-full flex-col gap-3">
<form
className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
data-testid="message-composer"
onDragEnter={media.handleDragEnter}
onDragLeave={media.handleDragLeave}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ArrowDown, X } from "lucide-react";
import { ArrowDown, ArrowLeft, X } from "lucide-react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
@@ -8,12 +8,14 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
@@ -36,6 +38,7 @@ type MessageThreadPanelProps = {
imetaMedia?: ImetaMedia[];
} | null;
isSending: boolean;
isSinglePanelView?: boolean;
onCancelEdit?: () => void;
onCancelReply: () => void;
onClose: () => void;
@@ -93,6 +96,7 @@ export function MessageThreadPanel({
disabled = false,
editTarget,
isSending,
isSinglePanelView = false,
isFollowingThread,
onCancelEdit,
onCancelReply,
@@ -124,8 +128,13 @@ export function MessageThreadPanel({
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
useComposerHeightPadding(threadBodyRef, threadComposerWrapperRef);
const isFloatingOverlay = isOverlay && !isSinglePanelView;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
useComposerHeightPadding(
threadBodyRef,
threadComposerWrapperRef,
isSinglePanelView,
);
const threadHeadId = threadHead?.id ?? null;
@@ -165,16 +174,24 @@ export function MessageThreadPanel({
return (
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
className={cn(
PANEL_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="message-thread-panel"
style={{ width: `${widthPx}px` }}
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
{!isOverlay && (
{!isOverlay && !isSinglePanelView && (
<button
aria-label="Resize thread panel"
className="peer/thread-resize group/thread-resize absolute inset-y-0 left-0 z-[60] w-3 -translate-x-1/2 cursor-col-resize"
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}
@@ -198,19 +215,43 @@ export function MessageThreadPanel({
<div
className={cn(
"z-50 flex cursor-default select-none items-center gap-3 px-3",
isOverlay
? "relative min-h-[44px] shrink-0 bg-background/80 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-11 min-h-[32px] py-[4px]",
"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:pl-[24px] 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-[42px] z-50 min-h-[32px] gap-3 px-3 py-[4px]",
)}
data-tauri-drag-region
>
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
<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-sm font-semibold leading-5 tracking-tight">
Thread
</h2>
</div>
<Button
aria-label="Close thread"
className="ml-auto h-4 w-4 rounded-full text-muted-foreground/45 opacity-70 hover:bg-muted/60 hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
className="ml-auto h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
@@ -223,8 +264,8 @@ export function MessageThreadPanel({
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto pb-24",
isOverlay ? "" : "pt-[76px]",
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
isSinglePanelView ? "pt-[76px]" : isOverlay ? "" : "pt-[76px]",
)}
data-testid="message-thread-body"
onScroll={syncScrollState}

View File

@@ -96,7 +96,7 @@ export function MessageThreadSummaryRow({
) : null}
<button
className="group inline-flex w-fit max-w-full items-center gap-1 text-left text-xs font-medium text-muted-foreground"
className="group inline-flex w-fit max-w-full cursor-pointer items-center gap-1 text-left text-xs font-medium text-muted-foreground transition-[color,opacity] hover:text-foreground hover:opacity-90 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}

View File

@@ -11,8 +11,10 @@ import * as React from "react";
export function useComposerHeightPadding(
scrollContainerRef: React.RefObject<HTMLElement | null>,
composerRef: React.RefObject<HTMLElement | null>,
resetKey?: unknown,
) {
React.useEffect(() => {
void resetKey;
const scrollEl = scrollContainerRef.current;
const composerEl = composerRef.current;
@@ -28,26 +30,41 @@ export function useComposerHeightPadding(
);
};
const observer = new ResizeObserver(([entry]) => {
const height =
entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
// Add a small buffer (8px) so the last message isn't flush against the composer
const padding = Math.ceil(height) + 8;
let lastPadding: number | null = null;
const applyPadding = (height: number) => {
const padding = Math.ceil(height);
if (lastPadding !== null && Math.abs(padding - lastPadding) <= 1) {
return;
}
const previousPadding = lastPadding;
const wasAtBottom = isNearBottom();
scrollEl.style.paddingBottom = `${padding}px`;
lastPadding = padding;
if (wasAtBottom) {
if (
wasAtBottom &&
(previousPadding === null || padding > previousPadding)
) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
};
const observer = new ResizeObserver(([entry]) => {
const height =
entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
applyPadding(height);
});
observer.observe(composerEl);
applyPadding(composerEl.getBoundingClientRect().height);
return () => {
observer.disconnect();
// Reset to a sensible default when unmounting
scrollEl.style.paddingBottom = "";
};
}, [scrollContainerRef, composerRef]);
}, [scrollContainerRef, composerRef, resetKey]);
}

View File

@@ -33,6 +33,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
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 { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Button } from "@/shared/ui/button";
@@ -40,11 +41,13 @@ import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
type UserProfilePanelProps = {
canResetWidth: boolean;
currentPubkey?: string;
isSinglePanelView?: boolean;
onClose: () => void;
onOpenDm?: (pubkeys: string[]) => void;
onResetWidth: () => void;
@@ -83,6 +86,7 @@ function truncatePubkey(pubkey: string) {
export function UserProfilePanel({
canResetWidth,
currentPubkey,
isSinglePanelView = false,
onClose,
onOpenDm,
onResetWidth,
@@ -91,7 +95,8 @@ export function UserProfilePanel({
widthPx,
}: UserProfilePanelProps) {
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
const isFloatingOverlay = isOverlay && !isSinglePanelView;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const profileQuery = useUserProfileQuery(pubkey);
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
@@ -187,20 +192,24 @@ export function UserProfilePanel({
return (
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="user-profile-panel"
style={{ width: `${widthPx}px` }}
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
{!isOverlay && (
{!isOverlay && !isSinglePanelView && (
<button
aria-label="Resize profile panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
className="peer/profile-resize group/profile-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="user-profile-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
@@ -211,27 +220,52 @@ export function UserProfilePanel({
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/profile-resize:bg-border/80 group-focus-visible/profile-resize:bg-border/80" />
</button>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold tracking-tight">Profile</h2>
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] 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"
/>
) : 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-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-[42px] z-50 min-h-[32px] gap-3 bg-transparent px-3 py-[4px] 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">
<h2 className="translate-y-px text-sm font-semibold leading-5 tracking-tight">
Profile
</h2>
</div>
<Button
aria-label="Close profile"
className="ml-auto 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="h-4 w-4" />
<X className="h-2.5 w-2.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4 pb-6",
!isFloatingOverlay && "pt-[76px]",
)}
>
<div className="flex flex-col items-center gap-4 pt-4">
{/* Avatar */}
{profile?.avatarUrl ? (

View File

@@ -661,7 +661,7 @@ export function AppSidebar({
/>
) : null}
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 shadow-[0_-16px_40px_hsl(var(--sidebar)/0.55)] backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:shadow-[0_-16px_44px_rgba(0,0,0,0.42)] dark:supports-[backdrop-filter]:bg-sidebar/35">
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
<SidebarMenu>
<SidebarMenuItem>
<div

View File

@@ -1,7 +1,8 @@
import * as React from "react";
import { THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/hooks/useThreadPanelWidth";
const MOBILE_BREAKPOINT = 768;
const THREAD_PANEL_OVERLAY_BREAKPOINT = 1024;
/**
* Returns `true` when the viewport is narrower than `breakpointPx`.
@@ -25,10 +26,66 @@ export function useMediaBreakpoint(breakpointPx: number): boolean {
return isBelow;
}
export function useElementWidthBreakpoint<T extends HTMLElement>(
breakpointPx: number,
): [React.RefObject<T | null>, boolean] {
const [ref, widthPx] = useElementWidth<T>();
return [ref, widthPx > 0 && widthPx < breakpointPx];
}
export function useElementWidth<T extends HTMLElement>(): [
React.RefObject<T | null>,
number,
] {
const ref = React.useRef<T>(null);
const [widthPx, setWidthPx] = React.useState(0);
React.useEffect(() => {
let frameId: number | null = null;
let cleanup: (() => void) | null = null;
const attach = () => {
const element = ref.current;
if (!element) {
frameId = window.requestAnimationFrame(attach);
return;
}
const updateWidth = () => {
setWidthPx(element.getBoundingClientRect().width);
};
updateWidth();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateWidth);
cleanup = () => window.removeEventListener("resize", updateWidth);
return;
}
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
cleanup = () => observer.disconnect();
};
attach();
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
cleanup?.();
};
}, []);
return [ref, widthPx];
}
export function useIsMobile() {
return useMediaBreakpoint(MOBILE_BREAKPOINT);
}
export function useIsThreadPanelOverlay() {
return useMediaBreakpoint(THREAD_PANEL_OVERLAY_BREAKPOINT);
return useMediaBreakpoint(THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX);
}

View File

@@ -1,7 +1,9 @@
import * as React from "react";
const THREAD_PANEL_DEFAULT_WIDTH_PX = 380;
const THREAD_PANEL_MIN_WIDTH_PX = 320;
export const THREAD_PANEL_MIN_WIDTH_PX = 300;
export const THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX =
THREAD_PANEL_MIN_WIDTH_PX * 2;
const THREAD_PANEL_MAX_WIDTH_PX = 720;
const THREAD_PANEL_WIDTH_SESSION_KEY = "sprout.desktop.thread-panel-width";

View File

@@ -7,9 +7,21 @@
export const PANEL_BASE_CLASS =
"relative flex h-full shrink-0 flex-col border-l border-border/80 bg-background";
/** Extra classes applied when the panel is rendered as a floating overlay. */
/**
* Extra classes applied when the panel is rendered as a floating overlay.
*
* Starts below the fixed top chrome (window drag region + global search and
* channel actions, ~44px tall) so the panel header doesn't collide with it at
* narrow widths. Matches the inline layout where the header sits below chrome.
*/
export const PANEL_OVERLAY_CLASS =
"fixed inset-y-0 right-0 z-40 shadow-xl max-w-[calc(100vw-2rem)]";
"fixed bottom-0 right-0 top-11 z-40 shadow-xl max-w-[calc(100vw-2rem)]";
/**
* Single-column panel headers should render above the local panel backdrop
* (z-40) but stay below global top chrome controls (z-[45]).
*/
export const PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS = "z-[41]";
type OverlayPanelBackdropProps = {
onClose: () => void;

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -14,16 +15,21 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
ref={ref}
{...props}
/>
));
>(({ className, ...props }, ref) => {
const { isDark } = useTheme();
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
isDark ? "bg-black/60" : "bg-black/10",
className,
)}
ref={ref}
{...props}
/>
);
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<

View File

@@ -429,7 +429,7 @@ function createMarkdownComponents(
<ImageContextMenu src={src}>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="mt-1 max-w-sm cursor-pointer transition-opacity hover:opacity-90">
<div className="mt-1 max-w-sm cursor-pointer">
<img
alt={alt}
className="max-h-64 max-w-full rounded-xl object-contain"

View File

@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
const Sheet = SheetPrimitive.Root;
@@ -18,16 +19,21 @@ const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
>(({ className, ...props }, ref) => {
const { isDark } = useTheme();
return (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
isDark ? "bg-black/80" : "bg-black/10",
className,
)}
{...props}
ref={ref}
/>
);
});
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(