mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Fix desktop right-side panel chrome overlap (#806)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -28,6 +28,7 @@ export type TranscriptItem =
|
||||
title: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
authorPubkey?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
18
desktop/src/features/channels/ui/ChannelScreen.types.ts
Normal file
18
desktop/src/features/channels/ui/ChannelScreen.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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={
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
desktop/src/features/home/lib/inboxViewHelpers.ts
Normal file
42
desktop/src/features/home/lib/inboxViewHelpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user