diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 480000bb54..d98c871659 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; @@ -17,7 +16,6 @@ export const Route = createFileRoute("/")({ function HomeRouteComponent() { const { goChannel } = useAppNavigation(); - const { openChannelManagement } = useAppShell(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); const channels = channelsQuery.data ?? []; @@ -67,7 +65,6 @@ function HomeRouteComponent() { ); } diff --git a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx new file mode 100644 index 0000000000..7a14546ada --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx @@ -0,0 +1,171 @@ +import { Archive, ArchiveRestore, Trash2 } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type ChannelMutation = { + error: unknown; + isPending: boolean; + mutateAsync: (args: TArgs) => Promise; +}; + +type ChannelManagementModerationActionsProps = { + archiveChannelMutation: ChannelMutation; + canManageChannel: boolean; + deleteChannelMutation: ChannelMutation; + handleDeleteChannel: () => Promise; + handleDeleteDialogOpenChange: (open: boolean) => void; + isArchived: boolean; + isDark: boolean; + isDeleteDialogOpen: boolean; + isOwner: boolean; + resolvedChannelName: string; + unarchiveChannelMutation: ChannelMutation; +}; + +export function ChannelManagementModerationActions({ + archiveChannelMutation, + canManageChannel, + deleteChannelMutation, + handleDeleteChannel, + handleDeleteDialogOpenChange, + isArchived, + isDark, + isDeleteDialogOpen, + isOwner, + resolvedChannelName, + unarchiveChannelMutation, +}: ChannelManagementModerationActionsProps) { + return ( +
+ {isArchived ? ( + + ) : ( + + )} + {isOwner ? ( + + + + + + + Delete channel? + + Delete {resolvedChannelName} from the workspace list. This + action cannot be undone. + + + {deleteChannelMutation.error instanceof Error ? ( +

+ {deleteChannelMutation.error.message} +

+ ) : null} + + + + + + + + +
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 0431e27fa7..fb86f99fd9 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -1,6 +1,5 @@ import { Archive, - ArchiveRestore, BookOpenText, ChevronLeft, Copy, @@ -19,6 +18,7 @@ import { Zap, } from "lucide-react"; import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; import { toast } from "sonner"; import { @@ -42,17 +42,6 @@ import { import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -62,15 +51,18 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/shared/ui/sheet"; import { Textarea } from "@/shared/ui/textarea"; +import { + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, + auxiliaryPanelContentPaddingClass, +} from "@/shared/layout/AuxiliaryPanelHeader"; +import { + OverlayPanelBackdrop, + PANEL_BASE_CLASS, + PANEL_OVERLAY_CLASS, +} from "@/shared/ui/OverlayPanelBackdrop"; import { ChannelCanvas } from "./ChannelCanvas"; import { ChannelHero, @@ -84,10 +76,12 @@ import { NarrativeGroup, ToggleRow, } from "./ChannelManagementSheetRows"; +import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions"; type ChannelManagementSheetProps = { channel: Channel | null; currentPubkey?: string; + layout?: "overlay" | "split"; onDeleted?: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -98,11 +92,13 @@ const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60; export function ChannelManagementSheet({ channel, currentPubkey, + layout = "overlay", onDeleted, onOpenChange, open, }: ChannelManagementSheetProps) { const { isDark } = useTheme(); + const isSplitLayout = layout === "split"; const channelId = channel?.id ?? null; const detailsQuery = useChannelDetailsQuery(channelId, open); const membersQuery = useChannelMembersQuery(channelId, open); @@ -214,7 +210,7 @@ export function ChannelManagementSheet({ } } - function handleSheetOpenChange(next: boolean) { + function handlePanelOpenChange(next: boolean) { if (!next) { handleDeleteDialogOpenChange(false); } @@ -299,358 +295,111 @@ export function ChannelManagementSheet({ } return ( - - button]:hidden", - isDark - ? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75" - : "bg-background", - )} - data-testid="channel-management-sheet" - side="right" - > - + {!isSplitLayout ? ( + + + handlePanelOpenChange(false)} + /> + + + ) : null} + {isSplitLayout ? ( + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} > -
- {activeView === "canvas" ? ( - - ) : null} - - {activeView === "canvas" ? "Canvas" : "Channel"} - -
- - - - - Channel settings - -
- -
- {activeView === "summary" ? ( -
- - - {detailsQuery.error instanceof Error ? ( -

- {detailsQuery.error.message} -

- ) : null} - - {membersQuery.error instanceof Error ? ( -

- {membersQuery.error.message} -

- ) : null} - -
- { - void navigator.clipboard - .writeText(resolvedChannel.id) - .then(() => toast.success("Copied channel ID")); - }} - testId="channel-management-copy-id-action" - /> - {canJoin ? ( - { - void joinChannelMutation.mutateAsync(); - }} - testId="channel-management-join" - /> - ) : null} - {canLeave ? ( - { - void leaveChannelMutation.mutateAsync().then(() => { - onOpenChange(false); - }); - }} - testId="channel-management-leave" - /> - ) : null} - {canManageChannel ? ( - setIsEditDialogOpen(true)} - testId="channel-management-edit" - /> - ) : null} -
- - {joinChannelMutation.error instanceof Error ? ( -

- {joinChannelMutation.error.message} -

- ) : null} - {leaveChannelMutation.error instanceof Error ? ( -

- {leaveChannelMutation.error.message} -

- ) : null} - - {resolvedChannel.description.trim() || - resolvedChannel.topic?.trim() || - resolvedChannel.purpose?.trim() ? ( - - {resolvedChannel.description.trim() ? ( - - ) : null} - {resolvedChannel.topic?.trim() ? ( - - ) : null} - {resolvedChannel.purpose?.trim() ? ( - - ) : null} - - ) : null} - - {canOpenCanvas ? ( - setActiveView("canvas")} - testId="channel-canvas-ingress" - trailing={canvasQuery.isLoading ? "Loading..." : undefined} - /> - ) : null} - - - - - - - - {isArchived ? ( - - ) : null} - {resolvedChannel.ttlSeconds !== null ? ( - - ) : null} - - - {canManageChannel && resolvedChannel.channelType !== "dm" ? ( -
-
- {isArchived ? ( - - ) : ( - - )} - {isOwner ? ( - - - - - - - Delete channel? - - Delete {resolvedChannel.name} from the workspace - list. This action cannot be undone. - - - {deleteChannelMutation.error instanceof Error ? ( -

- {deleteChannelMutation.error.message} -

- ) : null} - - - - - - - - -
-
- ) : null} -
- {archiveChannelMutation.error instanceof Error ? ( -

- {archiveChannelMutation.error.message} -

- ) : null} - {unarchiveChannelMutation.error instanceof Error ? ( -

- {unarchiveChannelMutation.error.message} -

- ) : null} -
- ) : null} -
- ) : ( -
- -
- )} -
-
+ + + ) : ( + + + + + + )} {canManageChannel ? ( @@ -843,6 +592,373 @@ export function ChannelManagementSheet({ ) : null} -
+ + ); +} + +type ChannelMutation = { + error: unknown; + isPending: boolean; + mutateAsync: (args: TArgs) => Promise; +}; + +type ChannelManagementPanelContentProps = { + activeView: "summary" | "canvas"; + archiveChannelMutation: ChannelMutation; + canEditNarrative: boolean; + canJoin: boolean; + canLeave: boolean; + canManageChannel: boolean; + canOpenCanvas: boolean; + canvasPreview?: string; + canvasQuery: { isLoading: boolean }; + channelId: string | null; + deleteChannelMutation: ChannelMutation; + detailsError: unknown; + handleDeleteChannel: () => Promise; + handleDeleteDialogOpenChange: (open: boolean) => void; + isArchived: boolean; + isDark: boolean; + isDeleteDialogOpen: boolean; + isOwner: boolean; + isSplitLayout: boolean; + joinChannelMutation: ChannelMutation; + leaveChannelMutation: ChannelMutation; + memberCount: number; + membersError: unknown; + onOpenChange: (open: boolean) => void; + resolvedChannel: Channel; + setActiveView: React.Dispatch>; + setIsEditDialogOpen: React.Dispatch>; + unarchiveChannelMutation: ChannelMutation; +}; + +function ChannelManagementPanelContent({ + activeView, + archiveChannelMutation, + canEditNarrative, + canJoin, + canLeave, + canManageChannel, + canOpenCanvas, + canvasPreview, + canvasQuery, + channelId, + deleteChannelMutation, + detailsError, + handleDeleteChannel, + handleDeleteDialogOpenChange, + isArchived, + isDark, + isDeleteDialogOpen, + isOwner, + isSplitLayout, + joinChannelMutation, + leaveChannelMutation, + memberCount, + membersError, + onOpenChange, + resolvedChannel, + setActiveView, + setIsEditDialogOpen, + unarchiveChannelMutation, +}: ChannelManagementPanelContentProps) { + const showModerationActions = + activeView === "summary" && + canManageChannel && + resolvedChannel.channelType !== "dm"; + + return ( + <> + {isSplitLayout ? ( + + + {activeView === "canvas" ? ( + + ) : null} + + + {activeView === "canvas" ? "Canvas" : "Channel"} + + + + + + + + Channel settings + + + ) : ( +
+
+ {activeView === "canvas" ? ( + + ) : null} + + {activeView === "canvas" ? "Canvas" : "Channel"} + +
+ + + + + Channel settings + +
+ )} + +
+ {activeView === "summary" ? ( +
+ + + {detailsError instanceof Error ? ( +

+ {detailsError.message} +

+ ) : null} + + {membersError instanceof Error ? ( +

+ {membersError.message} +

+ ) : null} + +
+ { + void navigator.clipboard + .writeText(resolvedChannel.id) + .then(() => toast.success("Copied channel ID")); + }} + testId="channel-management-copy-id-action" + /> + {canJoin ? ( + { + void joinChannelMutation.mutateAsync(); + }} + testId="channel-management-join" + /> + ) : null} + {canLeave ? ( + { + void leaveChannelMutation.mutateAsync().then(() => { + onOpenChange(false); + }); + }} + testId="channel-management-leave" + /> + ) : null} + {canManageChannel ? ( + setIsEditDialogOpen(true)} + testId="channel-management-edit" + /> + ) : null} +
+ + {joinChannelMutation.error instanceof Error ? ( +

+ {joinChannelMutation.error.message} +

+ ) : null} + {leaveChannelMutation.error instanceof Error ? ( +

+ {leaveChannelMutation.error.message} +

+ ) : null} + + {resolvedChannel.description.trim() || + resolvedChannel.topic?.trim() || + resolvedChannel.purpose?.trim() ? ( + + {resolvedChannel.description.trim() ? ( + + ) : null} + {resolvedChannel.topic?.trim() ? ( + + ) : null} + {resolvedChannel.purpose?.trim() ? ( + + ) : null} + + ) : null} + + {canOpenCanvas ? ( + setActiveView("canvas")} + testId="channel-canvas-ingress" + trailing={canvasQuery.isLoading ? "Loading..." : undefined} + /> + ) : null} + + + + + + + + {isArchived ? ( + + ) : null} + {resolvedChannel.ttlSeconds !== null ? ( + + ) : null} + + + {archiveChannelMutation.error instanceof Error ? ( +

+ {archiveChannelMutation.error.message} +

+ ) : null} + {unarchiveChannelMutation.error instanceof Error ? ( +

+ {unarchiveChannelMutation.error.message} +

+ ) : null} +
+ ) : ( +
+ +
+ )} +
+ + {showModerationActions ? ( + + ) : null} + ); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8227072031..21ca459808 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -21,6 +21,7 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping" import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; +import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { BotActivityComposerAction, @@ -61,6 +62,7 @@ type ChannelPaneProps = { agentSessionAgents: ChannelAgentSessionAgent[]; botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; + channelManagementOpen?: boolean; currentPubkey?: string; editTarget?: { author: string; @@ -85,6 +87,8 @@ type ChannelPaneProps = { onCancelEdit?: () => void; onCancelThreadReply: () => void; onCloseAgentSession: () => void; + onCloseChannelManagement?: () => void; + onChannelManagementDeleted?: () => void; onCloseProfilePanel: () => void; onAddAgent?: () => void; onCreateChannel?: () => void; @@ -164,6 +168,7 @@ export const ChannelPane = React.memo(function ChannelPane({ activityAgents = agentSessionAgents, botTypingEntries, channelFind, + channelManagementOpen = false, currentPubkey, editTarget = null, fetchOlder, @@ -184,6 +189,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit, onCancelThreadReply, onCloseAgentSession, + onCloseChannelManagement, + onChannelManagementDeleted, onCloseProfilePanel, onAddAgent, onCreateChannel, @@ -773,61 +780,24 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} - {threadHeadMessage + {channelManagementOpen && activeChannel ? (() => { const panel = ( - - ) : null + layout={ + useSplitAuxiliaryPane || isSinglePanelView + ? "split" + : "overlay" } + onOpenChange={(nextOpen) => { + if (!nextOpen) { + onCloseChannelManagement?.(); + } + }} + onDeleted={onChannelManagementDeleted} + open={true} /> ); return useSplitAuxiliaryPane ? ( @@ -835,7 +805,7 @@ export const ChannelPane = React.memo(function ChannelPane({ canResetWidth={canResetThreadPanelWidth} onResetWidth={onResetThreadPanelWidth} onResizeStart={onThreadPanelResizeStart} - testId="message-thread-panel" + testId="channel-management-auxiliary-pane" widthPx={threadPanelWidthPx} > {panel} @@ -844,16 +814,61 @@ export const ChannelPane = React.memo(function ChannelPane({ panel ); })() - : openThreadHeadId && activeChannel + : threadHeadMessage ? (() => { const panel = ( - + ) : null + } /> ); return useSplitAuxiliaryPane ? ( @@ -870,27 +885,15 @@ export const ChannelPane = React.memo(function ChannelPane({ panel ); })() - : activeChannel && selectedAgent + : openThreadHeadId && activeChannel ? (() => { const panel = ( - - entry.pubkey.toLowerCase() === - selectedAgent.pubkey.toLowerCase(), - )} + - onOpenProfilePanel(selectedAgent.pubkey) - } - onClose={onCloseAgentSession} + onClose={onCloseThread} widthPx={threadPanelWidthPx} /> ); @@ -899,7 +902,7 @@ export const ChannelPane = React.memo(function ChannelPane({ canResetWidth={canResetThreadPanelWidth} onResetWidth={onResetThreadPanelWidth} onResizeStart={onThreadPanelResizeStart} - testId="agent-session-thread-panel" + testId="message-thread-panel" widthPx={threadPanelWidthPx} > {panel} @@ -908,19 +911,27 @@ export const ChannelPane = React.memo(function ChannelPane({ panel ); })() - : profilePanelPubkey + : activeChannel && selectedAgent ? (() => { const panel = ( - + entry.pubkey.toLowerCase() === + selectedAgent.pubkey.toLowerCase(), + )} isSinglePanelView={ useSplitAuxiliaryPane ? false : isSinglePanelView } layout={useSplitAuxiliaryPane ? "split" : "standalone"} - onClose={onCloseProfilePanel} - onOpenDm={onOpenDm} - pubkey={profilePanelPubkey} - splitPaneClamp + profiles={profiles} + onBackToProfile={() => + onOpenProfilePanel(selectedAgent.pubkey) + } + onClose={onCloseAgentSession} widthPx={threadPanelWidthPx} /> ); @@ -929,7 +940,7 @@ export const ChannelPane = React.memo(function ChannelPane({ canResetWidth={canResetThreadPanelWidth} onResetWidth={onResetThreadPanelWidth} onResizeStart={onThreadPanelResizeStart} - testId="user-profile-panel" + testId="agent-session-thread-panel" widthPx={threadPanelWidthPx} > {panel} @@ -938,7 +949,37 @@ export const ChannelPane = React.memo(function ChannelPane({ panel ); })() - : null} + : profilePanelPubkey + ? (() => { + const panel = ( + + ); + return useSplitAuxiliaryPane ? ( + + {panel} + + ) : ( + panel + ); + })() + : null} ); }); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index fc98b98432..3459d7d2ee 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; import { @@ -93,6 +94,7 @@ export function ChannelScreen({ targetMessageEvents, targetMessageId, }: ChannelScreenProps) { + const { goHome } = useAppNavigation(); const { markChannelRead, markChannelUnread, @@ -101,7 +103,7 @@ export function ChannelScreen({ markThreadRead, setContextParentResolver, openCreateChannel, - openChannelManagement, + openChannelManagement: openGlobalChannelManagement, followThread, unfollowThread, isFollowingThread, @@ -111,6 +113,8 @@ export function ChannelScreen({ const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< string | null >(null); + const [isChannelManagementOpen, setIsChannelManagementOpen] = + React.useState(false); const { canReset: canResetThreadPanelWidth, onResetWidth: handleThreadPanelWidthReset, @@ -143,14 +147,8 @@ export function ChannelScreen({ useChannelSubscription(activeChannel); const { fetchOlder, hasOlderMessages, isFetchingOlder } = useFetchOlderMessages(activeChannel); - // Newest TOP-LEVEL message only. The channel read-marker must clear the - // channel timeline without clearing its threads (NIP-RS Option 1): thread - // replies are kind-9 channel events, so taking the last message outright - // would advance the channel frontier past unread replies and the hierarchical - // effective(thread) = max(thread, channel) would silently clear every thread - // badge on channel entry. Scanning from the end for the last message with no - // reply tag keeps the frontier at the last top-level message, leaving thread - // badges intact until the thread itself is read. + // Newest top-level message only: opening a channel should clear the timeline + // without clearing unread thread replies. const latestActiveMessage = React.useMemo(() => { const messages = messagesQuery.data; if (!messages) return null; @@ -161,22 +159,13 @@ export function ChannelScreen({ } return null; }, [messagesQuery.data]); - // No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend - // takes MAX(created_at) over kind-9 events without a parent filter), so using - // it when the window has no top-level message would advance the channel - // marker past an unread reply and clear its thread/sidebar unread — the exact - // regression Fix A prevents. null suppresses the marker advance (markChannelRead - // early-returns on markAt === null) until a real top-level position is known. + // No `lastMessageAt` fallback: it is reply-inclusive and would clear unread + // thread/sidebar state before a real top-level position is known. const activeReadAt = latestActiveMessage ? new Date(latestActiveMessage.created_at * 1_000).toISOString() : null; - // Capture the read frontier as it stood the instant this channel was opened, - // BEFORE the mark-read effect below advances it to latest. Written during - // render (not in an effect) so the value is read prior to any effect for - // this commit — the divider must reflect "what was unread on open", not the - // post-open frontier. Keyed per channel and recomputed only when the channel - // id changes, never when activeReadAt advances, or the divider would vanish - // the moment the open marks the channel read. + // Capture the read frontier during render, before the mark-read effect + // advances it, so dividers reflect "what was unread on open." const openFrontierRef = React.useRef(new Map()); if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) { openFrontierRef.current.set( @@ -187,12 +176,8 @@ export function ChannelScreen({ const openFrontierSeconds = activeChannelId ? (openFrontierRef.current.get(activeChannelId) ?? null) : null; - // Channels the user manually marked unread this session. A deliberate - // mark-unread has no meaningful "new" boundary inside the timeline — the - // open-time snapshot already covers every message — so the pill and divider - // would otherwise render nothing while the sidebar dot says unread. Suppress - // the marker for such channels to avoid that visible contradiction. The flag - // is cleared on re-open (a fresh snapshot is recomputed for the channel). + // Manual mark-unread has no meaningful in-timeline boundary, so suppress the + // marker until the channel is reopened and a fresh snapshot is captured. const forcedUnreadRef = React.useRef(new Set()); const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0); const isActiveChannelForcedUnread = @@ -209,9 +194,7 @@ export function ChannelScreen({ }; }, [activeChannelId]); // Clear the open-time frontier on channel leave so re-visiting captures a - // fresh read position. Without this, switching away and back would reuse the - // stale frontier from the first open, producing a phantom "New" divider over - // already-read messages. + // fresh read position. React.useEffect(() => { const channelId = activeChannelId; if (!channelId) return; @@ -223,19 +206,12 @@ export function ChannelScreen({ if (!activeChannelId || activeChannel?.isMember === false) { return; } - // Passive channel-open: advance the marker to the newest top-level message - // only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding - // in observed thread replies, so opening a channel clears the timeline but - // leaves thread badges intact until each thread is opened — and leaves the - // channel's sidebar dot lit (the reply is still unread for the channel). + // Advance only to the newest top-level message; thread replies remain + // unread until their thread is opened. markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]); - // Install the NIP-RS parent resolver: every `thread:` context evaluated - // while this channel is active belongs to it (getThreadReadAt is only ever - // called on the active channel's timeline messages), so the parent is always - // the active channel. Non-thread keys (channels) have no parent → null, which - // degrades effective() to the own term. Cleared on channel leave / unmount so - // a stale channel id never becomes the parent of another channel's threads. + // Install the NIP-RS parent resolver for active-channel thread contexts and + // clear it on leave so a stale channel id never parents another channel. React.useEffect(() => { if (!activeChannelId) { setContextParentResolver(null); @@ -438,10 +414,8 @@ export function ChannelScreen({ () => buildCreatedAtByMessageId(timelineMessages), [timelineMessages], ); - // Newest createdAt across an expanded branch (the message itself plus every - // descendant). Drilling into a branch advances the thread frontier to this, - // consuming everything chronologically up to the deepest reply read. Returns - // null when the message is absent so the caller skips the read-state write. + // Newest createdAt across an expanded branch, used to advance its thread + // frontier when the branch is drilled into. const getSubtreeMaxCreatedAt = React.useCallback( (messageId: string) => subtreeMaxCreatedAt( @@ -758,6 +732,7 @@ export function ChannelScreen({ handleCloseAgentSession(); setEditTargetId(null); setProfilePanelPubkey(null); + setIsChannelManagementOpen(false); }, [handleCloseAgentSession], ); @@ -810,7 +785,10 @@ export function ChannelScreen({ useLoadMissingAncestors(activeChannel, resolvedMessages); const hasAuxiliaryPanel = Boolean( - openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey, + openThreadHeadMessage || + openAgentSessionPubkey || + profilePanelPubkey || + isChannelManagementOpen, ); const isNarrowPanelViewport = channelContentWidthPx > 0 && @@ -851,7 +829,20 @@ export function ChannelScreen({ isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} - onManageChannel={openChannelManagement} + onManageChannel={() => { + if (activeChannel?.channelType === "forum") { + openGlobalChannelManagement(); + return; + } + + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + handleCloseAgentSession(); + setProfilePanelPubkey(null); + setIsChannelManagementOpen(true); + }} onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)} showHeaderContent={!isSinglePanelView} /> @@ -891,6 +882,7 @@ export function ChannelScreen({ agentSessionAgents={channelAgentSessionAgents} botTypingEntries={botTypingEntries} channelFind={channelFind} + channelManagementOpen={isChannelManagementOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} @@ -922,6 +914,10 @@ export function ChannelScreen({ messages={timelineMessages} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={() => { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} onFollowThread={ openThreadHeadId != null && !isNotifiedForCurrentThread ? () => followThread(openThreadHeadId) @@ -933,6 +929,9 @@ export function ChannelScreen({ : undefined } onCloseAgentSession={handleCloseAgentSession} + onCloseChannelManagement={() => + setIsChannelManagementOpen(false) + } onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 6dc74b073e..99be335f70 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/cn"; type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; + constrainToAvailableSpace?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; testId?: string; @@ -16,6 +17,7 @@ type RightAuxiliaryPaneProps = { export function RightAuxiliaryPane({ canResetWidth, children, + constrainToAvailableSpace = true, onResetWidth, onResizeStart, testId, @@ -26,7 +28,9 @@ export function RightAuxiliaryPane({ className="group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-(--buzz-top-chrome-height,2.5rem) before:z-40 before:w-px before:bg-border/80 before:content-['']" data-testid={testId} style={{ - maxWidth: `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`, + maxWidth: constrainToAvailableSpace + ? `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)` + : undefined, width: widthPx, }} > diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index add42ab460..5ea5390a96 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -13,13 +13,11 @@ import { type HomeScreenProps = { availableChannelIds: ReadonlySet; currentPubkey?: string; - onOpenChannel: (channelId: string) => void; }; export function HomeScreen({ availableChannelIds, currentPubkey, - onOpenChannel, }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); const { threadActivityItems } = useAppShell(); @@ -70,7 +68,6 @@ export function HomeScreen({ } feed={augmentedFeed} isLoading={homeFeedQuery.isLoading} - onOpenChannel={onOpenChannel} onRefresh={() => { void homeFeedQuery.refetch(); }} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 4325cb5a1f..95b697eec7 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -3,6 +3,8 @@ import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { type InboxFilter, type InboxContextMessage, @@ -44,6 +46,10 @@ import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { useElementWidth } from "@/shared/hooks/use-mobile"; +import { + THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX, + useThreadPanelWidth, +} from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; type HomeViewProps = { @@ -52,7 +58,6 @@ type HomeViewProps = { errorMessage?: string; currentPubkey?: string; availableChannelIds: ReadonlySet; - onOpenChannel: (channelId: string) => void; onRefresh: () => void; }; @@ -62,7 +67,6 @@ export function HomeView({ errorMessage, currentPubkey, availableChannelIds, - onOpenChannel, onRefresh, }: HomeViewProps) { const [homeInboxRef, homeInboxWidthPx] = useElementWidth(); @@ -75,9 +79,18 @@ export function HomeView({ ); const [isDeletingMessage, setIsDeletingMessage] = React.useState(false); const [isSendingReply, setIsSendingReply] = React.useState(false); + const [managedChannelId, setManagedChannelId] = React.useState( + null, + ); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< Record >({}); + const { + canReset: canResetThreadPanelWidth, + onResetWidth: handleThreadPanelWidthReset, + onResizeStart: handleThreadPanelResizeStart, + widthPx: threadPanelWidthPx, + } = useThreadPanelWidth(); const { canResetInboxListWidth, handleInboxListResizeStart, @@ -118,6 +131,15 @@ export function HomeView({ null ); }, [channels, selectedChannelIdCandidate]); + const managedChannel = React.useMemo(() => { + if (!managedChannelId || !channels) return null; + return channels.find((channel) => channel.id === managedChannelId) ?? null; + }, [channels, managedChannelId]); + const isChannelManagementOpen = managedChannel !== null; + const isSinglePanelChannelManagementView = + isChannelManagementOpen && + homeInboxWidthPx > 0 && + homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); const toggleReactionMutation = useToggleReactionMutation(); @@ -311,14 +333,24 @@ export function HomeView({ currentPubkey?.trim().toLowerCase() === selectedItem.item.pubkey.trim().toLowerCase(); const isSinglePanelDetailView = - isNarrowHomeViewport && selectedItemId !== null; - const showListPane = !isSinglePanelDetailView; - const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView; + isNarrowHomeViewport && + selectedItemId !== null && + !isSinglePanelChannelManagementView; + const showListPane = + !isSinglePanelDetailView && !isSinglePanelChannelManagementView; + const showDetailPane = + !isSinglePanelChannelManagementView && + (!isNarrowHomeViewport || isSinglePanelDetailView); + const channelManagementWidthPx = isSinglePanelChannelManagementView + ? homeInboxWidthPx + : threadPanelWidthPx; const maxEffectiveInboxListWidthPx = homeInboxWidthPx > 0 ? Math.max( INBOX_COLUMN_MIN_WIDTH_PX, - homeInboxWidthPx - INBOX_COLUMN_MIN_WIDTH_PX, + homeInboxWidthPx - + INBOX_COLUMN_MIN_WIDTH_PX - + (isChannelManagementOpen ? channelManagementWidthPx : 0), ) : undefined; const effectiveInboxListWidthPx = @@ -334,14 +366,21 @@ export function HomeView({
) : null} + {isChannelManagementOpen ? ( + + { + if (!nextOpen) { + setManagedChannelId(null); + } + }} + open={true} + /> + + ) : null}
); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 5dea2e21d5..d221bd606f 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -31,7 +31,7 @@ async function openChannelManagement( } async function closeChannelManagement(page: import("@playwright/test").Page) { - await page.keyboard.press("Escape"); + await page.getByTestId("channel-management-close").click(); await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible(); } @@ -1243,6 +1243,8 @@ test("manage channel keeps canvas near the top of the sheet", async ({ await openChannelManagement(page, "general"); const sheet = page.getByTestId("channel-management-sheet"); + const sheetBox = await sheet.boundingBox(); + const timelineBox = await page.getByTestId("message-timeline").boundingBox(); // Canvas ingress should appear before the channel metadata rows in the DOM. const canvasBox = await sheet @@ -1252,6 +1254,25 @@ test("manage channel keeps canvas near the top of the sheet", async ({ .getByTestId("channel-management-name-row") .boundingBox(); + expect(sheetBox).not.toBeNull(); + expect(timelineBox).not.toBeNull(); + if (!sheetBox || !timelineBox) { + throw new Error("Expected channel management panel and timeline boxes."); + } + expect(timelineBox.x + timelineBox.width).toBeLessThanOrEqual(sheetBox.x + 1); + await page.mouse.click(timelineBox.x + 24, timelineBox.y + 180); + await expect(sheet).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sheet).toBeVisible(); + await page.setViewportSize({ height: 720, width: 820 }); + await expect(page.getByTestId("message-timeline")).toHaveCount(0); + await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0); + await expect(sheet).toBeVisible(); + const narrowSheetBox = await sheet.boundingBox(); + if (!narrowSheetBox) { + throw new Error("Expected narrow channel management panel box."); + } + expect(narrowSheetBox.width).toBeGreaterThan(500); expect(canvasBox).not.toBeNull(); expect(nameBox).not.toBeNull(); expect(canvasBox?.y).toBeLessThan(nameBox?.y); @@ -1312,6 +1333,31 @@ test("home inbox channel label opens management without leaving home", async ({ "general", ); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + const detailBox = await page.getByTestId("home-inbox-detail").boundingBox(); + const sheetBox = await page + .getByTestId("channel-management-sheet") + .boundingBox(); + if (!detailBox || !sheetBox) { + throw new Error("Expected home detail pane and channel management boxes."); + } + expect(detailBox.x + detailBox.width).toBeLessThanOrEqual(sheetBox.x + 1); + expect(sheetBox.width).toBeGreaterThanOrEqual(300); + await page + .getByTestId("home-inbox-list") + .click({ position: { x: 24, y: 80 } }); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.setViewportSize({ height: 720, width: 820 }); + await expect(page.getByTestId("home-inbox-list")).toHaveCount(0); + const narrowHomeBox = await page.getByTestId("home-inbox").boundingBox(); + const narrowSheetBox = await page + .getByTestId("channel-management-sheet") + .boundingBox(); + if (!narrowHomeBox || !narrowSheetBox) { + throw new Error("Expected narrow home and channel management boxes."); + } + expect(narrowSheetBox.width).toBeGreaterThanOrEqual(narrowHomeBox.width - 1); await expect(page).not.toHaveURL(/#\/channels\//); });