From c54897ffcae72bbc7a67c96a141f01c5965fae87 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 11:58:30 -0400 Subject: [PATCH 1/8] feat(desktop): add user-defined channel sections to sidebar All stream channels appear in a single flat list which becomes hard to navigate as channel count grows. Channel sections let users create custom named groups (e.g., "Starred", "Work") to organize channels in the sidebar, similar to Slack's sidebar sections. Sections are purely cosmetic and client-side -- stored in localStorage keyed by pubkey, with no backend changes. Users manage sections via right-click context menus: create, rename, delete, reorder (move up/down), and assign/unassign channels. Each section is independently collapsible. Channels not assigned to a section remain in the default "Channels" group. --- desktop/scripts/check-file-sizes.mjs | 3 +- .../sidebar/lib/channelSectionsStorage.ts | 72 +++ .../sidebar/lib/useChannelSections.ts | 267 +++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 385 +++++-------- .../sidebar/ui/ChannelSectionDialogs.tsx | 220 +++++++ .../sidebar/ui/CustomChannelSection.tsx | 543 ++++++++++++++++++ 6 files changed, 1263 insertions(+), 227 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelSectionsStorage.ts create mode 100644 desktop/src/features/sidebar/lib/useChannelSections.ts create mode 100644 desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx create mode 100644 desktop/src/features/sidebar/ui/CustomChannelSection.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b52c3d636..3f02d5918 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -46,7 +46,8 @@ const overrides = new Map([ ["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up). ["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them ["src/features/settings/ui/SettingsView.tsx", 600], - ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav + ["src/features/sidebar/ui/AppSidebar.tsx", 785], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + ["src/features/sidebar/ui/CustomChannelSection.tsx", 545], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts ["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts new file mode 100644 index 000000000..8dc80ff45 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -0,0 +1,72 @@ +const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1"; + +export type ChannelSection = { + id: string; + name: string; + order: number; +}; + +export type ChannelSectionStore = { + version: 1; + sections: ChannelSection[]; + assignments: Record; +}; + +export const DEFAULT_STORE: ChannelSectionStore = { + version: 1, + sections: [], + assignments: {}, +}; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +export function readChannelSectionsStore(pubkey: string): ChannelSectionStore { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) { + return DEFAULT_STORE; + } + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) { + return DEFAULT_STORE; + } + const sections: ChannelSection[] = Array.isArray(parsed.sections) + ? parsed.sections.filter( + (entry: unknown): entry is ChannelSection => + typeof entry === "object" && + entry !== null && + typeof (entry as Record).id === "string" && + typeof (entry as Record).name === "string" && + typeof (entry as Record).order === "number", + ) + : []; + const assignments: Record = + typeof parsed.assignments === "object" && + parsed.assignments !== null && + !Array.isArray(parsed.assignments) + ? Object.fromEntries( + Object.entries(parsed.assignments).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string", + ), + ) + : {}; + return { version: 1, sections, assignments }; + } catch { + return DEFAULT_STORE; + } +} + +export function writeChannelSectionsStore( + pubkey: string, + store: ChannelSectionStore, +): boolean { + try { + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + return true; + } catch { + return false; + } +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts new file mode 100644 index 000000000..50fb99244 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -0,0 +1,267 @@ +import * as React from "react"; + +import { + DEFAULT_STORE, + readChannelSectionsStore, + writeChannelSectionsStore, +} from "./channelSectionsStorage"; + +export type { ChannelSection } from "./channelSectionsStorage"; + +import type { + ChannelSection, + ChannelSectionStore, +} from "./channelSectionsStorage"; + +const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1"; + +function storageKey(pubkey: string): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}`; +} + +export function useChannelSections(pubkey: string | undefined): { + sections: ChannelSection[]; + assignments: Record; + createSection: (name: string) => ChannelSection; + renameSection: (sectionId: string, newName: string) => void; + deleteSection: (sectionId: string) => void; + moveSectionUp: (sectionId: string) => void; + moveSectionDown: (sectionId: string) => void; + assignChannel: (channelId: string, sectionId: string) => void; + unassignChannel: (channelId: string) => void; +} { + const [store, setStore] = React.useState(() => { + if (!pubkey) { + return DEFAULT_STORE; + } + return readChannelSectionsStore(pubkey); + }); + + React.useEffect(() => { + if (!pubkey) { + setStore(DEFAULT_STORE); + return; + } + setStore(readChannelSectionsStore(pubkey)); + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) { + return; + } + const key = storageKey(pubkey); + const handler = (e: StorageEvent) => { + if (e.key !== key) { + return; + } + setStore(readChannelSectionsStore(pubkey)); + }; + window.addEventListener("storage", handler); + return () => { + window.removeEventListener("storage", handler); + }; + }, [pubkey]); + + const sections = React.useMemo( + () => store.sections.slice().sort((a, b) => a.order - b.order), + [store.sections], + ); + + const createSection = React.useCallback( + (name: string): ChannelSection => { + let created!: ChannelSection; + if (!pubkey) { + created = { id: crypto.randomUUID(), name, order: 0 }; + return created; + } + setStore((prev) => { + const maxOrder = + prev.sections.length > 0 + ? Math.max(...prev.sections.map((s) => s.order)) + : -1; + const section: ChannelSection = { + id: crypto.randomUUID(), + name, + order: maxOrder + 1, + }; + created = section; + const next: ChannelSectionStore = { + ...prev, + sections: [...prev.sections, section], + }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + return created; + }, + [pubkey], + ); + + const renameSection = React.useCallback( + (sectionId: string, newName: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const next: ChannelSectionStore = { + ...prev, + sections: prev.sections.map((s) => + s.id === sectionId ? { ...s, name: newName } : s, + ), + }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const deleteSection = React.useCallback( + (sectionId: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const assignments = { ...prev.assignments }; + for (const channelId of Object.keys(assignments)) { + if (assignments[channelId] === sectionId) { + delete assignments[channelId]; + } + } + const next: ChannelSectionStore = { + ...prev, + sections: prev.sections.filter((s) => s.id !== sectionId), + assignments, + }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const moveSectionUp = React.useCallback( + (sectionId: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const target = prev.sections.find((s) => s.id === sectionId); + if (!target) { + return prev; + } + const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((s) => s.id === sectionId); + if (idx <= 0) { + return prev; + } + const neighbor = sorted[idx - 1]; + const sections = prev.sections.map((s) => { + if (s.id === target.id) { + return { ...s, order: neighbor.order }; + } + if (s.id === neighbor.id) { + return { ...s, order: target.order }; + } + return s; + }); + const next: ChannelSectionStore = { ...prev, sections }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const moveSectionDown = React.useCallback( + (sectionId: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const target = prev.sections.find((s) => s.id === sectionId); + if (!target) { + return prev; + } + const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((s) => s.id === sectionId); + if (idx < 0 || idx >= sorted.length - 1) { + return prev; + } + const neighbor = sorted[idx + 1]; + const sections = prev.sections.map((s) => { + if (s.id === target.id) { + return { ...s, order: neighbor.order }; + } + if (s.id === neighbor.id) { + return { ...s, order: target.order }; + } + return s; + }); + const next: ChannelSectionStore = { ...prev, sections }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const assignChannel = React.useCallback( + (channelId: string, sectionId: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const next: ChannelSectionStore = { + ...prev, + assignments: { ...prev.assignments, [channelId]: sectionId }, + }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + const unassignChannel = React.useCallback( + (channelId: string) => { + if (!pubkey) { + return; + } + setStore((prev) => { + const assignments = { ...prev.assignments }; + delete assignments[channelId]; + const next: ChannelSectionStore = { ...prev, assignments }; + if (!writeChannelSectionsStore(pubkey, next)) { + return prev; + } + return next; + }); + }, + [pubkey], + ); + + return { + sections, + assignments: store.assignments, + createSection, + renameSection, + deleteSection, + moveSectionUp, + moveSectionDown, + assignChannel, + unassignChannel, + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 1e36ce978..6f73a9643 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -4,15 +4,9 @@ import { ArrowDown, ArrowUp, Bot, - CheckCheck, - CheckCircle2, - ChevronDown, - CircleDot, FolderGit2, Home, PenSquare, - Plus, - Search, Zap, } from "lucide-react"; import * as React from "react"; @@ -26,14 +20,25 @@ import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; +import { + useChannelSections, + type ChannelSection, +} from "@/features/sidebar/lib/useChannelSections"; import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; +import { + CreateSectionDialog, + DeleteSectionAlertDialog, + RenameSectionDialog, +} from "@/features/sidebar/ui/ChannelSectionDialogs"; import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton"; +import { SidebarSection } from "@/features/sidebar/ui/SidebarSection"; import { - ChannelMenuButton, - SidebarSection, -} from "@/features/sidebar/ui/SidebarSection"; + ChannelGroupSection, + CustomChannelSection, + SECTION_ACTION_VISIBILITY_CLASS, +} from "@/features/sidebar/ui/CustomChannelSection"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import type { @@ -44,12 +49,6 @@ import type { UserStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/shared/ui/context-menu"; import { Sidebar, SidebarContent, @@ -66,19 +65,6 @@ import { SidebarMenuSkeleton, } from "@/shared/ui/sidebar"; -// --------------------------------------------------------------------------- -// Shared styles -// --------------------------------------------------------------------------- - -const SECTION_ICON_BUTTON_CLASS = - "flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"; -const SECTION_ACTION_VISIBILITY_CLASS = - "opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100"; -const SECTION_LABEL_BUTTON_CLASS = - "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; -const SECTION_LABEL_CHEVRON_CLASS = - "h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; - type CollapsibleSidebarGroup = "channels" | "forums" | "directMessages"; // --------------------------------------------------------------------------- @@ -165,202 +151,6 @@ type AppSidebarProps = { onCreateChannelOpenChange?: (open: boolean) => void; }; -// --------------------------------------------------------------------------- -// SectionHeaderActions — browse + create icon buttons for section headers -// --------------------------------------------------------------------------- - -function SectionHeaderActions({ - browseAriaLabel, - browseTestId, - className, - createAriaLabel, - hasUnread, - onBrowse, - onCreateClick, - onMarkAllRead, -}: { - browseAriaLabel: string; - browseTestId?: string; - className?: string; - createAriaLabel: string; - hasUnread?: boolean; - onBrowse: () => void; - onCreateClick: () => void; - onMarkAllRead?: () => void; -}) { - return ( -
- {hasUnread && onMarkAllRead ? ( - - ) : null} - - -
- ); -} - -// --------------------------------------------------------------------------- -// ChannelGroupSection — unified Channels / Forums section (no inline form) -// --------------------------------------------------------------------------- - -function ChannelGroupSection({ - browseAriaLabel, - browseTestId, - createAriaLabel, - groupClassName, - hasUnread, - isCollapsed, - isActiveChannel, - items, - listTestId, - onBrowse, - onCreateClick, - onMarkAllRead, - onMarkChannelRead, - onMarkChannelUnread, - onSelectChannel, - onToggleCollapsed, - selectedChannelId, - title, - unreadChannelIds, -}: { - browseAriaLabel: string; - browseTestId?: string; - createAriaLabel: string; - groupClassName?: string; - isCollapsed: boolean; - isActiveChannel: boolean; - items: Channel[]; - listTestId: string; - onBrowse: () => void; - onCreateClick: () => void; - onMarkChannelRead: ( - channelId: string, - lastMessageAt: string | null | undefined, - ) => void; - onMarkChannelUnread: ( - channelId: string, - lastMessageAt: string | null | undefined, - ) => void; - onSelectChannel: (channelId: string) => void; - onToggleCollapsed: () => void; - selectedChannelId: string | null; - title: string; - unreadChannelIds: Set; - hasUnread?: boolean; - onMarkAllRead?: () => void; -}) { - const contentId = `sidebar-${listTestId}`; - - return ( - -
- - - - -
- {!isCollapsed ? ( - - {items.length > 0 ? ( - - {items.map((channel) => ( - - - - - - - - {unreadChannelIds.has(channel.id) ? ( - - onMarkChannelRead(channel.id, channel.lastMessageAt) - } - > - - Mark as read - - ) : ( - - onMarkChannelUnread(channel.id, channel.lastMessageAt) - } - > - - Mark unread - - )} - - - ))} - - ) : null} - - ) : null} -
- ); -} - // --------------------------------------------------------------------------- // AppSidebar // --------------------------------------------------------------------------- @@ -453,10 +243,79 @@ export function AppSidebar({ [], ); + const [collapsedSections, setCollapsedSections] = React.useState< + Record + >({}); + const toggleCollapsedSection = React.useCallback((sectionId: string) => { + setCollapsedSections((current) => ({ + ...current, + [sectionId]: !current[sectionId], + })); + }, []); + + const { + sections: channelSections, + assignments: channelAssignments, + createSection, + renameSection, + deleteSection, + moveSectionUp, + moveSectionDown, + assignChannel, + unassignChannel, + } = useChannelSections(currentPubkey); + + const [createSectionState, setCreateSectionState] = React.useState<{ + open: boolean; + pendingChannelId: string | null; + }>({ open: false, pendingChannelId: null }); + const [renameSectionTarget, setRenameSectionTarget] = + React.useState(null); + const [deleteSectionTarget, setDeleteSectionTarget] = + React.useState(null); + const streamChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "stream"), [channels], ); + + const sectionBuckets = React.useMemo(() => { + const bySection: Record = {}; + const unassigned: Channel[] = []; + const sectionIds = new Set(channelSections.map((s) => s.id)); + + for (const channel of streamChannels) { + const sectionId = channelAssignments[channel.id]; + if (sectionId && sectionIds.has(sectionId)) { + if (!bySection[sectionId]) { + bySection[sectionId] = []; + } + bySection[sectionId].push(channel); + } else { + unassigned.push(channel); + } + } + return { bySection, unassigned }; + }, [streamChannels, channelSections, channelAssignments]); + + const handleCreateSectionForChannel = React.useCallback( + (channelId: string) => { + setCreateSectionState({ open: true, pendingChannelId: channelId }); + }, + [], + ); + + const handleCreateSectionConfirm = React.useCallback( + (name: string) => { + const section = createSection(name); + if (createSectionState.pendingChannelId) { + assignChannel(createSectionState.pendingChannelId, section.id); + } + setCreateSectionState({ open: false, pendingChannelId: null }); + }, + [createSection, assignChannel, createSectionState.pendingChannelId], + ); + const forumChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "forum"), [channels], @@ -641,15 +500,41 @@ export function AppSidebar({ {!isLoading ? ( <> + {channelSections.map((section, idx) => ( + toggleCollapsedSection(section.id)} + onSelectChannel={onSelectChannel} + onMarkChannelRead={onMarkChannelRead} + onMarkChannelUnread={onMarkChannelUnread} + onAssignChannel={assignChannel} + onUnassignChannel={unassignChannel} + onCreateSectionForChannel={handleCreateSectionForChannel} + onRenameSection={() => setRenameSectionTarget(section)} + onDeleteSection={() => setDeleteSectionTarget(section)} + onMoveSectionUp={() => moveSectionUp(section.id)} + onMoveSectionDown={() => moveSectionDown(section.id)} + /> + ))} 0 ? undefined : "pt-1"} hasUnread={unreadChannelIds.size > 0} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} - items={streamChannels} + items={sectionBuckets.unassigned} listTestId="stream-list" onBrowse={onOpenBrowseChannels} onCreateClick={() => setCreateDialogKind("stream")} @@ -661,6 +546,11 @@ export function AppSidebar({ selectedChannelId={selectedChannelId} title="Channels" unreadChannelIds={unreadChannelIds} + sections={channelSections} + assignments={channelAssignments} + onAssignChannel={assignChannel} + onUnassignChannel={unassignChannel} + onCreateSectionForChannel={handleCreateSectionForChannel} /> + + { + if (!open) { + setCreateSectionState({ open: false, pendingChannelId: null }); + } + }} + onConfirm={handleCreateSectionConfirm} + /> + + { + if (!open) setRenameSectionTarget(null); + }} + sectionName={renameSectionTarget?.name ?? ""} + onConfirm={(newName) => { + if (renameSectionTarget) { + renameSection(renameSectionTarget.id, newName); + } + setRenameSectionTarget(null); + }} + /> + + { + if (!open) setDeleteSectionTarget(null); + }} + sectionName={deleteSectionTarget?.name ?? ""} + channelCount={ + deleteSectionTarget + ? (sectionBuckets.bySection[deleteSectionTarget.id]?.length ?? 0) + : 0 + } + onConfirm={() => { + if (deleteSectionTarget) { + deleteSection(deleteSectionTarget.id); + } + setDeleteSectionTarget(null); + }} + /> ); } diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx new file mode 100644 index 000000000..aad7969ab --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx @@ -0,0 +1,220 @@ +import * as React from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +// --------------------------------------------------------------------------- +// CreateSectionDialog +// --------------------------------------------------------------------------- + +type CreateSectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; +}; + +export function CreateSectionDialog({ + open, + onOpenChange, + onConfirm, +}: CreateSectionDialogProps) { + const [name, setName] = React.useState(""); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + + setName(""); + + // Small delay to let dialog animation start before focusing + const timerId = globalThis.setTimeout(() => { + inputRef.current?.focus(); + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [open]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + onConfirm(trimmed); + } + + return ( + + + + Create section + + Sections let you group related channels in the sidebar. + + +
+ setName(event.target.value)} + placeholder="Section name" + ref={inputRef} + spellCheck={false} + value={name} + /> +
+ + + + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// RenameSectionDialog +// --------------------------------------------------------------------------- + +type RenameSectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sectionName: string; + onConfirm: (newName: string) => void; +}; + +export function RenameSectionDialog({ + open, + onOpenChange, + sectionName, + onConfirm, +}: RenameSectionDialogProps) { + const [name, setName] = React.useState(sectionName); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + + setName(sectionName); + + // Small delay to let dialog animation start before focusing + const timerId = globalThis.setTimeout(() => { + inputRef.current?.focus(); + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [open, sectionName]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed || trimmed === sectionName) return; + onConfirm(trimmed); + } + + const trimmed = name.trim(); + const isDisabled = trimmed.length === 0 || trimmed === sectionName; + + return ( + + + + Rename section + + Enter a new name for this section. + + +
+ setName(event.target.value)} + placeholder="Section name" + ref={inputRef} + spellCheck={false} + value={name} + /> +
+ + + + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// DeleteSectionAlertDialog +// --------------------------------------------------------------------------- + +type DeleteSectionAlertDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sectionName: string; + channelCount: number; + onConfirm: () => void; +}; + +export function DeleteSectionAlertDialog({ + open, + onOpenChange, + sectionName, + channelCount, + onConfirm, +}: DeleteSectionAlertDialogProps) { + const channelLabel = + channelCount === 1 ? "1 channel" : `${channelCount} channels`; + const description = + channelCount === 0 + ? `Delete section "${sectionName}"? It has no channels.` + : `Delete section "${sectionName}"? Its ${channelLabel} will move back to the default Channels group.`; + + return ( + + + + Delete section + {description} + + + Cancel + + Delete + + + + + ); +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx new file mode 100644 index 000000000..029e3b2d4 --- /dev/null +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -0,0 +1,543 @@ +import { + ArrowDown, + ArrowUp, + Check, + CheckCheck, + CheckCircle2, + ChevronDown, + CircleDot, + Pencil, + Plus, + Search, + Trash2, +} from "lucide-react"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem, +} from "@/shared/ui/sidebar"; +import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection"; +import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +// --------------------------------------------------------------------------- +// Shared styles +// --------------------------------------------------------------------------- + +const SECTION_ICON_BUTTON_CLASS = + "flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"; +export const SECTION_ACTION_VISIBILITY_CLASS = + "opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100"; +const SECTION_LABEL_BUTTON_CLASS = + "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; +const SECTION_LABEL_CHEVRON_CLASS = + "h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; + +// --------------------------------------------------------------------------- +// MoveToSectionSubmenu — internal helper +// --------------------------------------------------------------------------- + +function MoveToSectionSubmenu({ + channelId, + sections, + assignments, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, +}: { + channelId: string; + sections: ChannelSection[]; + assignments: Record; + onAssignChannel: (channelId: string, sectionId: string) => void; + onUnassignChannel: (channelId: string) => void; + onCreateSectionForChannel: (channelId: string) => void; +}) { + const currentSectionId = assignments[channelId]; + + return ( + + Move to section + + {sections.map((section) => ( + onAssignChannel(channelId, section.id)} + > + {currentSectionId === section.id ? ( + + ) : ( + + )} + {section.name} + + ))} + {sections.length > 0 ? : null} + onCreateSectionForChannel(channelId)}> + + New section... + + {currentSectionId ? ( + onUnassignChannel(channelId)}> + Remove from section + + ) : null} + + + ); +} + +// --------------------------------------------------------------------------- +// ChannelContextMenuItems — shared context menu items for channel rows +// --------------------------------------------------------------------------- + +export function ChannelContextMenuItems({ + channel, + hasUnread, + sections, + assignments, + onMarkChannelRead, + onMarkChannelUnread, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, +}: { + channel: Channel; + hasUnread: boolean; + sections?: ChannelSection[]; + assignments?: Record; + onMarkChannelRead: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onMarkChannelUnread: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onAssignChannel?: (channelId: string, sectionId: string) => void; + onUnassignChannel?: (channelId: string) => void; + onCreateSectionForChannel?: (channelId: string) => void; +}) { + return ( + <> + {hasUnread ? ( + onMarkChannelRead(channel.id, channel.lastMessageAt)} + > + + Mark as read + + ) : ( + onMarkChannelUnread(channel.id, channel.lastMessageAt)} + > + + Mark unread + + )} + {sections && + assignments && + onAssignChannel && + onUnassignChannel && + onCreateSectionForChannel ? ( + <> + + + + ) : null} + + ); +} + +// --------------------------------------------------------------------------- +// SectionHeaderActions — browse + create icon buttons for section headers +// --------------------------------------------------------------------------- + +function SectionHeaderActions({ + browseAriaLabel, + browseTestId, + className, + createAriaLabel, + hasUnread, + onBrowse, + onCreateClick, + onMarkAllRead, +}: { + browseAriaLabel: string; + browseTestId?: string; + className?: string; + createAriaLabel: string; + hasUnread?: boolean; + onBrowse: () => void; + onCreateClick: () => void; + onMarkAllRead?: () => void; +}) { + return ( +
+ {hasUnread && onMarkAllRead ? ( + + ) : null} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// ChannelGroupSection — unified Channels / Forums section (no inline form) +// --------------------------------------------------------------------------- + +export function ChannelGroupSection({ + browseAriaLabel, + browseTestId, + createAriaLabel, + groupClassName, + hasUnread, + isCollapsed, + isActiveChannel, + items, + listTestId, + onBrowse, + onCreateClick, + onMarkAllRead, + onMarkChannelRead, + onMarkChannelUnread, + onSelectChannel, + onToggleCollapsed, + selectedChannelId, + title, + unreadChannelIds, + sections, + assignments, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, +}: { + browseAriaLabel: string; + browseTestId?: string; + createAriaLabel: string; + groupClassName?: string; + isCollapsed: boolean; + isActiveChannel: boolean; + items: Channel[]; + listTestId: string; + onBrowse: () => void; + onCreateClick: () => void; + onMarkChannelRead: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onMarkChannelUnread: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onSelectChannel: (channelId: string) => void; + onToggleCollapsed: () => void; + selectedChannelId: string | null; + title: string; + unreadChannelIds: Set; + hasUnread?: boolean; + onMarkAllRead?: () => void; + sections?: ChannelSection[]; + assignments?: Record; + onAssignChannel?: (channelId: string, sectionId: string) => void; + onUnassignChannel?: (channelId: string) => void; + onCreateSectionForChannel?: (channelId: string) => void; +}) { + const contentId = `sidebar-${listTestId}`; + + return ( + +
+ + + + +
+ {!isCollapsed ? ( + + {items.length > 0 ? ( + + {items.map((channel) => ( + + + + + + + + + + + ))} + + ) : null} + + ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// CustomChannelSection — user-defined channel section with management actions +// --------------------------------------------------------------------------- + +export function CustomChannelSection({ + section, + channels, + isCollapsed, + isActiveChannel, + selectedChannelId, + unreadChannelIds, + sections, + assignments, + isFirst, + isLast, + onToggleCollapsed, + onSelectChannel, + onMarkChannelRead, + onMarkChannelUnread, + onAssignChannel, + onUnassignChannel, + onCreateSectionForChannel, + onRenameSection, + onDeleteSection, + onMoveSectionUp, + onMoveSectionDown, +}: { + section: ChannelSection; + channels: Channel[]; + isCollapsed: boolean; + isActiveChannel: boolean; + selectedChannelId: string | null; + unreadChannelIds: Set; + sections: ChannelSection[]; + assignments: Record; + isFirst: boolean; + isLast: boolean; + onToggleCollapsed: () => void; + onSelectChannel: (channelId: string) => void; + onMarkChannelRead: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onMarkChannelUnread: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; + onAssignChannel: (channelId: string, sectionId: string) => void; + onUnassignChannel: (channelId: string) => void; + onCreateSectionForChannel: (channelId: string) => void; + onRenameSection: () => void; + onDeleteSection: () => void; + onMoveSectionUp: () => void; + onMoveSectionDown: () => void; +}) { + const contentId = `sidebar-section-${section.id}`; + + return ( + + + +
+ + + +
+ + +
+
+
+ + + + Rename section + + + + Move up + + + + Move down + + + + + Delete section + + +
+ {!isCollapsed ? ( + + {channels.length > 0 ? ( + + {channels.map((channel) => ( + + + + + + + + + + + ))} + + ) : null} + + ) : null} +
+ ); +} From 263d85b84f3ac8a93205cdcb0e6890aceaf087f8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 12:23:41 -0400 Subject: [PATCH 2/8] fix(desktop): address review findings in channel sections Export storageKey from channelSectionsStorage to eliminate duplicate key definitions that could diverge during refactors. Fix createSection to return null on write failure instead of a phantom section, preventing stale channel assignments. Clean up collapsedSections state when a section is deleted. Freeze DEFAULT_STORE to guard against accidental mutation. --- .../sidebar/lib/channelSectionsStorage.ts | 6 +++--- .../features/sidebar/lib/useChannelSections.ts | 18 ++++++------------ desktop/src/features/sidebar/ui/AppSidebar.tsx | 8 ++++++++ 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 8dc80ff45..254df2348 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -12,13 +12,13 @@ export type ChannelSectionStore = { assignments: Record; }; -export const DEFAULT_STORE: ChannelSectionStore = { +export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({ version: 1, sections: [], assignments: {}, -}; +}); -function storageKey(pubkey: string): string { +export function storageKey(pubkey: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}`; } diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 50fb99244..878475875 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -3,6 +3,7 @@ import * as React from "react"; import { DEFAULT_STORE, readChannelSectionsStore, + storageKey, writeChannelSectionsStore, } from "./channelSectionsStorage"; @@ -13,16 +14,10 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; -const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1"; - -function storageKey(pubkey: string): string { - return `${STORAGE_KEY_PREFIX}:${pubkey}`; -} - export function useChannelSections(pubkey: string | undefined): { sections: ChannelSection[]; assignments: Record; - createSection: (name: string) => ChannelSection; + createSection: (name: string) => ChannelSection | null; renameSection: (sectionId: string, newName: string) => void; deleteSection: (sectionId: string) => void; moveSectionUp: (sectionId: string) => void; @@ -68,12 +63,11 @@ export function useChannelSections(pubkey: string | undefined): { ); const createSection = React.useCallback( - (name: string): ChannelSection => { - let created!: ChannelSection; + (name: string): ChannelSection | null => { if (!pubkey) { - created = { id: crypto.randomUUID(), name, order: 0 }; - return created; + return null; } + let created: ChannelSection | null = null; setStore((prev) => { const maxOrder = prev.sections.length > 0 @@ -84,7 +78,6 @@ export function useChannelSections(pubkey: string | undefined): { name, order: maxOrder + 1, }; - created = section; const next: ChannelSectionStore = { ...prev, sections: [...prev.sections, section], @@ -92,6 +85,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + created = section; return next; }); return created; diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6f73a9643..8f5a18535 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -308,6 +308,9 @@ export function AppSidebar({ const handleCreateSectionConfirm = React.useCallback( (name: string) => { const section = createSection(name); + if (!section) { + return; + } if (createSectionState.pendingChannelId) { assignChannel(createSectionState.pendingChannelId, section.id); } @@ -777,6 +780,11 @@ export function AppSidebar({ onConfirm={() => { if (deleteSectionTarget) { deleteSection(deleteSectionTarget.id); + setCollapsedSections((prev) => { + const next = { ...prev }; + delete next[deleteSectionTarget.id]; + return next; + }); } setDeleteSectionTarget(null); }} From 695192947e91d52386cd832f5fc4b1a18cb9940b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 15:56:34 -0400 Subject: [PATCH 3/8] fix(desktop): address review findings in channel sections Extract shared parseChannelSectionPayload validator and stripOrphanedAssignments helper to channelSectionsStorage.ts. Deduplicate Create/Rename dialogs into shared SectionNameDialog base. Add per-section mark-all-read button to CustomChannelSection. --- desktop/scripts/check-file-sizes.mjs | 2 +- .../sidebar/lib/channelSectionsStorage.ts | 63 +++++--- .../src/features/sidebar/ui/AppSidebar.tsx | 12 ++ .../sidebar/ui/ChannelSectionDialogs.tsx | 140 ++++++++---------- .../sidebar/ui/CustomChannelSection.tsx | 18 +++ 5 files changed, 137 insertions(+), 98 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3f02d5918..a22652300 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -47,7 +47,7 @@ const overrides = new Map([ ["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 785], // channels + forums creation forms + Pulse nav + channel sections state/dialogs - ["src/features/sidebar/ui/CustomChannelSection.tsx", 545], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + ["src/features/sidebar/ui/CustomChannelSection.tsx", 565], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts ["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 254df2348..a5e22528c 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -22,6 +22,46 @@ export function storageKey(pubkey: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}`; } +export function stripOrphanedAssignments( + store: ChannelSectionStore, +): ChannelSectionStore { + const sectionIds = new Set(store.sections.map((s) => s.id)); + const cleaned = Object.fromEntries( + Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)), + ); + if (Object.keys(cleaned).length === Object.keys(store.assignments).length) + return store; + return { ...store, assignments: cleaned }; +} + +export function parseChannelSectionPayload( + json: unknown, +): ChannelSectionStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + const sections: ChannelSection[] = Array.isArray(obj.sections) + ? obj.sections.filter( + (entry: unknown): entry is ChannelSection => + typeof entry === "object" && + entry !== null && + typeof (entry as Record).id === "string" && + typeof (entry as Record).name === "string" && + typeof (entry as Record).order === "number", + ) + : []; + const assignments: Record = + typeof obj.assignments === "object" && + obj.assignments !== null && + !Array.isArray(obj.assignments) + ? Object.fromEntries( + Object.entries(obj.assignments as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : {}; + return stripOrphanedAssignments({ version: 1, sections, assignments }); +} + export function readChannelSectionsStore(pubkey: string): ChannelSectionStore { try { const raw = window.localStorage.getItem(storageKey(pubkey)); @@ -32,28 +72,7 @@ export function readChannelSectionsStore(pubkey: string): ChannelSectionStore { if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) { return DEFAULT_STORE; } - const sections: ChannelSection[] = Array.isArray(parsed.sections) - ? parsed.sections.filter( - (entry: unknown): entry is ChannelSection => - typeof entry === "object" && - entry !== null && - typeof (entry as Record).id === "string" && - typeof (entry as Record).name === "string" && - typeof (entry as Record).order === "number", - ) - : []; - const assignments: Record = - typeof parsed.assignments === "object" && - parsed.assignments !== null && - !Array.isArray(parsed.assignments) - ? Object.fromEntries( - Object.entries(parsed.assignments).filter( - (entry): entry is [string, string] => - typeof entry[1] === "string", - ), - ) - : {}; - return { version: 1, sections, assignments }; + return parseChannelSectionPayload(parsed) ?? DEFAULT_STORE; } catch { return DEFAULT_STORE; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 8f5a18535..80a5587ad 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -508,6 +508,11 @@ export function AppSidebar({ key={section.id} section={section} channels={sectionBuckets.bySection[section.id] ?? []} + hasUnread={ + sectionBuckets.bySection[section.id]?.some((c) => + unreadChannelIds.has(c.id), + ) ?? false + } isCollapsed={collapsedSections[section.id] ?? false} isActiveChannel={selectedView === "channel"} selectedChannelId={selectedChannelId} @@ -520,6 +525,13 @@ export function AppSidebar({ onSelectChannel={onSelectChannel} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onMarkSectionRead={() => { + for (const channel of sectionBuckets.bySection[ + section.id + ] ?? []) { + onMarkChannelRead(channel.id, channel.lastMessageAt); + } + }} onAssignChannel={assignChannel} onUnassignChannel={unassignChannel} onCreateSectionForChannel={handleCreateSectionForChannel} diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx index aad7969ab..c25f14ddf 100644 --- a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx +++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx @@ -22,39 +22,47 @@ import { import { Input } from "@/shared/ui/input"; // --------------------------------------------------------------------------- -// CreateSectionDialog +// SectionNameDialog (internal) // --------------------------------------------------------------------------- -type CreateSectionDialogProps = { +type SectionNameDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; + title: string; + description: string; + initialValue: string; + confirmLabel: string; + isConfirmDisabled: (trimmed: string) => boolean; onConfirm: (name: string) => void; }; -export function CreateSectionDialog({ +function SectionNameDialog({ open, onOpenChange, + title, + description, + initialValue, + confirmLabel, + isConfirmDisabled, onConfirm, -}: CreateSectionDialogProps) { - const [name, setName] = React.useState(""); +}: SectionNameDialogProps) { + const [name, setName] = React.useState(initialValue); const inputRef = React.useRef(null); React.useEffect(() => { if (!open) return; - - setName(""); - + setName(initialValue); // Small delay to let dialog animation start before focusing const timerId = globalThis.setTimeout(() => { inputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [open]); + }, [open, initialValue]); function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmed = name.trim(); - if (!trimmed) return; + if (isConfirmDisabled(trimmed)) return; onConfirm(trimmed); } @@ -62,10 +70,8 @@ export function CreateSectionDialog({ - Create section - - Sections let you group related channels in the sidebar. - + {title} + {description}
-
@@ -94,11 +100,40 @@ export function CreateSectionDialog({ ); } +// --------------------------------------------------------------------------- +// CreateSectionDialog +// --------------------------------------------------------------------------- + +export type CreateSectionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; +}; + +export function CreateSectionDialog({ + open, + onOpenChange, + onConfirm, +}: CreateSectionDialogProps) { + return ( + trimmed.length === 0} + onConfirm={onConfirm} + /> + ); +} + // --------------------------------------------------------------------------- // RenameSectionDialog // --------------------------------------------------------------------------- -type RenameSectionDialogProps = { +export type RenameSectionDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; sectionName: string; @@ -111,64 +146,19 @@ export function RenameSectionDialog({ sectionName, onConfirm, }: RenameSectionDialogProps) { - const [name, setName] = React.useState(sectionName); - const inputRef = React.useRef(null); - - React.useEffect(() => { - if (!open) return; - - setName(sectionName); - - // Small delay to let dialog animation start before focusing - const timerId = globalThis.setTimeout(() => { - inputRef.current?.focus(); - }, 50); - return () => globalThis.clearTimeout(timerId); - }, [open, sectionName]); - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - const trimmed = name.trim(); - if (!trimmed || trimmed === sectionName) return; - onConfirm(trimmed); - } - - const trimmed = name.trim(); - const isDisabled = trimmed.length === 0 || trimmed === sectionName; - return ( - - - - Rename section - - Enter a new name for this section. - - -
- setName(event.target.value)} - placeholder="Section name" - ref={inputRef} - spellCheck={false} - value={name} - /> -
- - - - -
-
-
-
+ + trimmed.length === 0 || trimmed === sectionName + } + onConfirm={onConfirm} + /> ); } @@ -176,7 +166,7 @@ export function RenameSectionDialog({ // DeleteSectionAlertDialog // --------------------------------------------------------------------------- -type DeleteSectionAlertDialogProps = { +export type DeleteSectionAlertDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; sectionName: string; diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 029e3b2d4..69fbe189e 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -374,6 +374,7 @@ export function ChannelGroupSection({ export function CustomChannelSection({ section, channels, + hasUnread, isCollapsed, isActiveChannel, selectedChannelId, @@ -386,6 +387,7 @@ export function CustomChannelSection({ onSelectChannel, onMarkChannelRead, onMarkChannelUnread, + onMarkSectionRead, onAssignChannel, onUnassignChannel, onCreateSectionForChannel, @@ -396,6 +398,7 @@ export function CustomChannelSection({ }: { section: ChannelSection; channels: Channel[]; + hasUnread: boolean; isCollapsed: boolean; isActiveChannel: boolean; selectedChannelId: string | null; @@ -414,6 +417,7 @@ export function CustomChannelSection({ channelId: string, lastMessageAt: string | null | undefined, ) => void; + onMarkSectionRead: () => void; onAssignChannel: (channelId: string, sectionId: string) => void; onUnassignChannel: (channelId: string) => void; onCreateSectionForChannel: (channelId: string) => void; @@ -453,6 +457,20 @@ export function CustomChannelSection({ SECTION_ACTION_VISIBILITY_CLASS, )} > + {hasUnread ? ( + + ) : null} + +
- - -
- {hasUnread ? ( - - ) : null} - + ) : null} + + +
+
+ + + + + Rename section + + + + Move up + + + + Move down + + + - - - - - - - - - - Rename section - - - - Move up - - - - Move down - - - - - Delete section - - - - {!isCollapsed ? ( - - {channels.length > 0 ? ( - - {channels.map((channel) => ( - - - - - - - - - - - ))} - + + Delete section + + + + {!isCollapsed ? ( + + + {channels.length > 0 ? ( + + {channels.map((channel) => ( + + + + + + + + + + + + + ))} + + ) : null} + + ) : null} - - ) : null} - + + )} + ); } diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx new file mode 100644 index 000000000..87fe3f3ef --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -0,0 +1,297 @@ +// biome-ignore format: keep compact to stay within file size limit +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Hash } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +// --------------------------------------------------------------------------- +// Data attribute shapes used on draggable / droppable items +// --------------------------------------------------------------------------- + +export type DndChannelData = { type: "channel"; channelId: string }; +export type DndSectionData = { type: "section"; sectionId: string }; +export type DndSectionDropData = { type: "section-drop"; sectionId: string }; +export type DndUngroupedData = { type: "ungrouped" }; + +// --------------------------------------------------------------------------- +// DraggableChannelRow — wraps a channel list item with useDraggable +// --------------------------------------------------------------------------- + +export function DraggableChannelRow({ + channelId, + children, +}: { + channelId: string; + children: React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: channelId, + data: { type: "channel", channelId } satisfies DndChannelData, + }); + + return ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// DroppableSectionBody — makes a section body accept channel drops +// --------------------------------------------------------------------------- + +export function DroppableSectionBody({ + sectionId, + children, + className, +}: { + sectionId: string; + children: React.ReactNode; + className?: string; +}) { + const droppableId = `section-drop:${sectionId}`; + const { setNodeRef, isOver } = useDroppable({ + id: droppableId, + data: { type: "section-drop", sectionId } satisfies DndSectionDropData, + }); + + return ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// DroppableUngroupedBody — makes the ungrouped Channels section a drop target +// --------------------------------------------------------------------------- + +export function DroppableUngroupedBody({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: "ungrouped", + data: { type: "ungrouped" } satisfies DndUngroupedData, + }); + + return ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// SortableSectionShell — wraps a CustomChannelSection header area +// --------------------------------------------------------------------------- + +export function SortableSectionShell({ + sectionId, + children, +}: { + sectionId: string; + children: (props: { + dragHandleProps: React.HTMLAttributes; + isDragging: boolean; + style: React.CSSProperties; + }) => React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: sectionId, + data: { type: "section", sectionId } satisfies DndSectionData, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ {children({ + dragHandleProps: { ...attributes, ...listeners }, + isDragging, + style, + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// DragOverlayChannel — ghost preview for a channel being dragged +// --------------------------------------------------------------------------- + +export function DragOverlayChannel({ name }: { name: string }) { + return ( +
+ + {name} +
+ ); +} + +// --------------------------------------------------------------------------- +// DragOverlaySection — ghost preview for a section header being dragged +// --------------------------------------------------------------------------- + +export function DragOverlaySection({ name }: { name: string }) { + return ( +
+ {name} +
+ ); +} + +// --------------------------------------------------------------------------- +// SidebarDndContext — DndContext + SortableContext + DragOverlay wrapper +// --------------------------------------------------------------------------- + +type SidebarDragItem = + | { type: "channel"; channelId: string; channelName: string } + | { type: "section"; sectionId: string; sectionName: string }; + +export function SidebarDndContext({ + sectionIds, + channels, + sections, + children, + onAssignChannel, + onUnassignChannel, + onReorderSections, +}: { + sectionIds: string[]; + channels: { id: string; name: string }[]; + sections: { id: string; name: string }[]; + children: React.ReactNode; + onAssignChannel: (channelId: string, sectionId: string) => void; + onUnassignChannel: (channelId: string) => void; + onReorderSections: (orderedIds: string[]) => void; +}) { + const [activeDragItem, setActiveDragItem] = + React.useState(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); + + const handleDragStart = React.useCallback( + (event: DragStartEvent) => { + const data = event.active.data.current; + if (!data) return; + if (data.type === "channel") { + const ch = channels.find((c) => c.id === data.channelId); + if (ch) + setActiveDragItem({ + type: "channel", + channelId: ch.id, + channelName: ch.name, + }); + } else if (data.type === "section") { + const sec = sections.find((s) => s.id === data.sectionId); + if (sec) + setActiveDragItem({ + type: "section", + sectionId: sec.id, + sectionName: sec.name, + }); + } + }, + [channels, sections], + ); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + setActiveDragItem(null); + const { active, over } = event; + if (!over) return; + const activeData = active.data.current; + const overData = over.data.current; + if (!activeData) return; + if (activeData.type === "channel") { + const channelId = activeData.channelId as string; + if (overData?.type === "section-drop") { + onAssignChannel(channelId, overData.sectionId as string); + } else if (overData?.type === "ungrouped") { + onUnassignChannel(channelId); + } + } else if (activeData.type === "section") { + const oldIdx = sectionIds.indexOf(active.id as string); + const newIdx = sectionIds.indexOf(over.id as string); + if (oldIdx !== -1 && newIdx !== -1 && oldIdx !== newIdx) { + onReorderSections(arrayMove(sectionIds, oldIdx, newIdx)); + } + } + }, + [sectionIds, onAssignChannel, onUnassignChannel, onReorderSections], + ); + + return ( + + + {children} + + + {activeDragItem?.type === "channel" ? ( + + ) : activeDragItem?.type === "section" ? ( + + ) : null} + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2013cf79f..76df97392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: desktop: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.6) '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -449,6 +458,28 @@ packages: cpu: [x64] os: [win32] + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1359,7 +1390,6 @@ packages: engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} @@ -2778,7 +2808,7 @@ packages: engines: {node: '>= 0.4'} wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2945,6 +2975,31 @@ snapshots: '@biomejs/cli-win32-x64@2.4.16': optional: true + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 From e9bca972dac20b3a5d475a2a946454805832a4bc Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 18:28:02 -0400 Subject: [PATCH 6/8] fix(desktop): align DnD drop zones with visual section boundaries closestCenter collision detection required dragging to the vertical midpoint of a tall channel list before the drop target activated. Switch to pointerWithin so the drop fires as soon as the pointer enters the droppable rect, and move the droppable wrappers up to cover the entire section (header + content) instead of just the channel list. --- .../sidebar/ui/CustomChannelSection.tsx | 208 +++++++++--------- .../src/features/sidebar/ui/SidebarDnd.tsx | 4 +- 2 files changed, 106 insertions(+), 106 deletions(-) diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index defa6a4f1..3dab3da05 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -351,7 +351,7 @@ export function ChannelGroupSection({ ) : null; - return ( + const sectionContent = (
@@ -384,16 +384,16 @@ export function ChannelGroupSection({ />
{!isCollapsed ? ( - - {draggable ? ( - {channelList} - ) : ( - channelList - )} - + {channelList} ) : null}
); + + return draggable ? ( + {sectionContent} + ) : ( + sectionContent + ); } // --------------------------------------------------------------------------- @@ -460,109 +460,109 @@ export function CustomChannelSection({ return ( {({ dragHandleProps, isDragging }) => ( - - - -
- - - + + + +
- {hasUnread ? ( + + +
+ {hasUnread ? ( + + ) : null} + - ) : null} - - + +
-
-
- - - - Rename section - - - - Move up - - - - Move down - - - - - Delete section - - -
- {!isCollapsed ? ( - - + + + + + Rename section + + + + Move up + + + + Move down + + + + + Delete section + + + + {!isCollapsed ? ( + {channels.length > 0 ? ( {channels.map((channel) => ( @@ -601,10 +601,10 @@ export function CustomChannelSection({ ))} ) : null} - - - ) : null} -
+ + ) : null} + + )}
); diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx index 87fe3f3ef..bab72b269 100644 --- a/desktop/src/features/sidebar/ui/SidebarDnd.tsx +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -3,7 +3,7 @@ import { DndContext, DragOverlay, PointerSensor, - closestCenter, + pointerWithin, useDraggable, useDroppable, useSensor, @@ -274,7 +274,7 @@ export function SidebarDndContext({ return ( Date: Mon, 1 Jun 2026 12:47:07 -0400 Subject: [PATCH 7/8] fix(desktop): bump AppSidebar file size limit after keyboard shortcuts merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main merged #809 (⌘⇧N shortcut) which added 23 lines to AppSidebar.tsx for the controlled create-channel dialog. Bump override from 810 to 830. --- desktop/scripts/check-file-sizes.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 69f6fb5ed..fa26f0285 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -46,7 +46,7 @@ const overrides = new Map([ ["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up). ["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them ["src/features/settings/ui/SettingsView.tsx", 600], - ["src/features/sidebar/ui/AppSidebar.tsx", 810], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + ["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut ["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts ["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests From 4be9357a6d4b686575b402e8d73b159cceb5448d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 13:20:55 -0400 Subject: [PATCH 8/8] fix(desktop): resolve section DnD drop target to correct ID pointerWithin was resolving over.id to the nested DroppableSectionBody ("section-drop:X") instead of the SortableSectionShell ("X"), causing sectionIds.indexOf to return -1 and silently skip the reorder. Read sectionId from over.data.current instead, which both registrations set. --- desktop/src/features/sidebar/ui/SidebarDnd.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/sidebar/ui/SidebarDnd.tsx b/desktop/src/features/sidebar/ui/SidebarDnd.tsx index bab72b269..8a5a09caa 100644 --- a/desktop/src/features/sidebar/ui/SidebarDnd.tsx +++ b/desktop/src/features/sidebar/ui/SidebarDnd.tsx @@ -262,8 +262,10 @@ export function SidebarDndContext({ onUnassignChannel(channelId); } } else if (activeData.type === "section") { + const overSectionId = + (overData?.sectionId as string | undefined) ?? (over.id as string); const oldIdx = sectionIds.indexOf(active.id as string); - const newIdx = sectionIds.indexOf(over.id as string); + const newIdx = sectionIds.indexOf(overSectionId); if (oldIdx !== -1 && newIdx !== -1 && oldIdx !== newIdx) { onReorderSections(arrayMove(sectionIds, oldIdx, newIdx)); }