diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ea993af99..192e3e424 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -67,7 +67,12 @@ export function AppShell() { const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); - const [isBrowseChannelsOpen, setIsBrowseChannelsOpen] = React.useState(false); + const [browseDialogType, setBrowseDialogType] = React.useState< + "stream" | "forum" | null + >(null); + const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => { + setBrowseDialogType(open ? "stream" : null); + }, []); const [searchAnchor, setSearchAnchor] = React.useState( null, ); @@ -547,7 +552,11 @@ export function AppShell() { openChannelView(createdForum.id); }} onOpenBrowseChannels={() => { - setIsBrowseChannelsOpen(true); + setBrowseDialogType("stream"); + void refetchChannels(); + }} + onOpenBrowseForums={() => { + setBrowseDialogType("forum"); void refetchChannels(); }} onOpenSearch={() => { @@ -707,10 +716,11 @@ export function AppShell() { void; onJoinChannel: (channelId: string) => Promise; @@ -80,6 +81,7 @@ type ChannelBrowserDialogProps = { export function ChannelBrowserDialog({ channels, + channelTypeFilter, open, onOpenChange, onJoinChannel, @@ -93,12 +95,23 @@ export function ChannelBrowserDialog({ const inputRef = React.useRef(null); const deferredQuery = React.useDeferredValue(query.trim().toLowerCase()); + const isForumMode = channelTypeFilter === "forum"; + const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels"; + const browseDescription = isForumMode + ? "Discover and join open forums." + : "Discover and join open channels."; + const searchPlaceholder = isForumMode + ? "Search forums by name or description" + : "Search channels by name or description"; + const entityLabel = isForumMode ? "forum" : "channel"; + const browsableChannels = React.useMemo(() => { const filtered = channels.filter( (channel) => channel.channelType !== "dm" && channel.visibility === "open" && - !channel.archivedAt, + !channel.archivedAt && + (channelTypeFilter ? channel.channelType === channelTypeFilter : true), ); if (deferredQuery.length === 0) { @@ -110,7 +123,7 @@ export function ChannelBrowserDialog({ channel.name.toLowerCase().includes(deferredQuery) || channel.description.toLowerCase().includes(deferredQuery), ); - }, [channels, deferredQuery]); + }, [channels, channelTypeFilter, deferredQuery]); const notJoined = React.useMemo( () => browsableChannels.filter((channel) => !channel.isMember), @@ -129,6 +142,10 @@ export function ChannelBrowserDialog({ ); React.useEffect(() => { + if (isForumMode) { + return; + } + function handleKeyDown(event: KeyboardEvent) { if ( event.key.toLowerCase() !== BROWSE_CHANNELS_SHORTCUT_KEY || @@ -147,7 +164,7 @@ export function ChannelBrowserDialog({ return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [onOpenChange]); + }, [isForumMode, onOpenChange]); React.useEffect(() => { if (!open) { @@ -204,18 +221,18 @@ export function ChannelBrowserDialog({ - Browse Channels + {browseTitle} - - Discover and join open channels. - + {browseDescription}
@@ -265,13 +282,13 @@ export function ChannelBrowserDialog({ ) : ( ) ) : ( @@ -280,7 +297,7 @@ export function ChannelBrowserDialog({ <>
- {notJoined.length} channel + {notJoined.length} {entityLabel} {notJoined.length !== 1 ? "s" : ""} to join Enter to join @@ -333,7 +350,7 @@ export function ChannelBrowserDialog({
- Showing open channels. Private channels require an invite. + Showing open {entityLabel}s. Private {entityLabel}s require an invite.
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9ceaca1e6..b417e59bd 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,5 +1,5 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Bot, Compass, Home, PenSquare, Plus, Search } from "lucide-react"; +import { Bot, Home, PenSquare, Plus, Search } from "lucide-react"; import * as React from "react"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; @@ -32,6 +32,17 @@ import { SidebarSeparator, } 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"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + type AppSidebarProps = { channels: Channel[]; currentPubkey?: string; @@ -56,6 +67,7 @@ type AppSidebarProps = { description?: string; }) => Promise; onOpenBrowseChannels: () => void; + onOpenBrowseForums: () => void; onOpenSearch: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; onSelectAgents: () => void; @@ -64,99 +76,243 @@ type AppSidebarProps = { onSelectSettings: () => void; }; -function StreamsSection({ - items, +// --------------------------------------------------------------------------- +// useCreateForm — shared state + handler for channel/forum creation +// --------------------------------------------------------------------------- + +function useCreateForm( + onCreate: (input: { name: string; description?: string }) => Promise, + entityLabel: string, +) { + const [isOpen, setIsOpen] = React.useState(false); + const [draftName, setDraftName] = React.useState(""); + const [draftDescription, setDraftDescription] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + + function toggle() { + setErrorMessage(undefined); + setIsOpen((current) => !current); + } + + function cancel() { + setErrorMessage(undefined); + setDraftName(""); + setDraftDescription(""); + setIsOpen(false); + } + + function changeName(value: string) { + setErrorMessage(undefined); + setDraftName(value); + } + + function changeDescription(value: string) { + setErrorMessage(undefined); + setDraftDescription(value); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const name = draftName.trim(); + const description = draftDescription.trim(); + if (!name) { + return; + } + + setErrorMessage(undefined); + + try { + await onCreate({ + name, + description: description || undefined, + }); + + setDraftName(""); + setDraftDescription(""); + setIsOpen(false); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : `Failed to create ${entityLabel}.`, + ); + } + } + + return { + isOpen, + draftName, + draftDescription, + errorMessage, + inputRef, + toggle, + cancel, + changeName, + changeDescription, + handleSubmit, + }; +} + +// --------------------------------------------------------------------------- +// SectionHeaderActions — search + create icon buttons for section headers +// --------------------------------------------------------------------------- + +function SectionHeaderActions({ + browseAriaLabel, + browseTestId, + createAriaLabel, + closeAriaLabel, isCreateOpen, - isCreatingChannel, - draftName, - draftDescription, - createInputRef, - createErrorMessage, + onBrowse, onToggleCreate, - onChangeName, - onChangeDescription, - onCreateChannel, - onCancelCreate, - onSelectChannel, - isActiveChannel, - selectedChannelId, - unreadChannelIds, }: { - items: Channel[]; + browseAriaLabel: string; + browseTestId?: string; + createAriaLabel: string; + closeAriaLabel: string; isCreateOpen: boolean; - isCreatingChannel: boolean; - draftName: string; - draftDescription: string; - createInputRef: React.RefObject; - createErrorMessage?: string; + onBrowse: () => void; onToggleCreate: () => void; - onChangeName: (value: string) => void; - onChangeDescription: (value: string) => void; - onCreateChannel: (event: React.FormEvent) => void; - onCancelCreate: () => void; - onSelectChannel: (channelId: string) => void; - isActiveChannel: boolean; - selectedChannelId: string | null; - unreadChannelIds: Set; }) { return ( - - Channels - + + + + ); +} + +// --------------------------------------------------------------------------- +// ChannelGroupSection — unified Channels / Forums section +// --------------------------------------------------------------------------- + +function ChannelGroupSection({ + browseAriaLabel, + browseTestId, + closeAriaLabel, + createAriaLabel, + createFormTestId, + createNameTestId, + createDescriptionTestId, + groupClassName, + isActiveChannel, + isCreating, + items, + listTestId, + namePlaceholder, + descriptionPlaceholder, + onBrowse, + onSelectChannel, + selectedChannelId, + title, + unreadChannelIds, + form, +}: { + browseAriaLabel: string; + browseTestId?: string; + closeAriaLabel: string; + createAriaLabel: string; + createFormTestId: string; + createNameTestId: string; + createDescriptionTestId: string; + groupClassName?: string; + isActiveChannel: boolean; + isCreating: boolean; + items: Channel[]; + listTestId: string; + namePlaceholder: string; + descriptionPlaceholder: string; + onBrowse: () => void; + onSelectChannel: (channelId: string) => void; + selectedChannelId: string | null; + title: string; + unreadChannelIds: Set; + form: ReturnType; +}) { + return ( + + {title} + - {isCreateOpen ? ( + {form.isOpen ? (
{ + void form.handleSubmit(event); + }} > onChangeName(event.target.value)} - placeholder="release-notes" - ref={createInputRef} + data-testid={createNameTestId} + disabled={isCreating} + onChange={(event) => form.changeName(event.target.value)} + placeholder={namePlaceholder} + ref={form.inputRef} spellCheck={false} - value={draftName} + value={form.draftName} /> onChangeDescription(event.target.value)} - placeholder="What this stream is for" - value={draftDescription} + data-testid={createDescriptionTestId} + disabled={isCreating} + onChange={(event) => form.changeDescription(event.target.value)} + placeholder={descriptionPlaceholder} + value={form.draftDescription} />
- {createErrorMessage ? ( -

{createErrorMessage}

+ {form.errorMessage ? ( +

{form.errorMessage}

) : null}
) : null} {items.length > 0 ? ( - + {items.map((channel) => ( ; - createErrorMessage?: string; - onToggleCreate: () => void; - onChangeName: (value: string) => void; - onChangeDescription: (value: string) => void; - onCreateForum: (event: React.FormEvent) => void; - onCancelCreate: () => void; - onSelectChannel: (channelId: string) => void; - isActiveChannel: boolean; - selectedChannelId: string | null; - unreadChannelIds: Set; -}) { - return ( - - Forums - - - - - {isCreateOpen ? ( -
- onChangeName(event.target.value)} - placeholder="design-discussions" - ref={createInputRef} - spellCheck={false} - value={draftName} - /> - onChangeDescription(event.target.value)} - placeholder="What this forum is for" - value={draftDescription} - /> -
- - -
- {createErrorMessage ? ( -

{createErrorMessage}

- ) : null} -
- ) : null} - - {items.length > 0 ? ( - - {items.map((channel) => ( - - - - ))} - - ) : null} -
-
- ); -} +// --------------------------------------------------------------------------- +// AppSidebar +// --------------------------------------------------------------------------- export function AppSidebar({ channels, @@ -332,6 +367,7 @@ export function AppSidebar({ onCreateChannel, onCreateForum, onOpenBrowseChannels, + onOpenBrowseForums, onOpenSearch, onOpenDm, onSelectAgents, @@ -340,21 +376,11 @@ export function AppSidebar({ onSelectSettings, }: AppSidebarProps) { const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; - const [isCreateOpen, setIsCreateOpen] = React.useState(false); - const [isForumCreateOpen, setIsForumCreateOpen] = React.useState(false); const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); - const [draftName, setDraftName] = React.useState(""); - const [draftDescription, setDraftDescription] = React.useState(""); - const [forumDraftName, setForumDraftName] = React.useState(""); - const [forumDraftDescription, setForumDraftDescription] = React.useState(""); - const [createErrorMessage, setCreateErrorMessage] = React.useState< - string | undefined - >(); - const [forumCreateErrorMessage, setForumCreateErrorMessage] = React.useState< - string | undefined - >(); - const createInputRef = React.useRef(null); - const forumCreateInputRef = React.useRef(null); + + const streamForm = useCreateForm(onCreateChannel, "stream"); + const forumForm = useCreateForm(onCreateForum, "forum"); + const streamChannels = channels.filter( (channel) => channel.channelType === "stream", ); @@ -380,76 +406,6 @@ export function AppSidebar({ fallbackDisplayName?.trim() || "Current identity"; - React.useEffect(() => { - if (!isCreateOpen) { - return; - } - - createInputRef.current?.focus(); - }, [isCreateOpen]); - - async function handleCreateChannel(event: React.FormEvent) { - event.preventDefault(); - - const name = draftName.trim(); - const description = draftDescription.trim(); - if (!name) { - return; - } - - setCreateErrorMessage(undefined); - - try { - await onCreateChannel({ - name, - description: description || undefined, - }); - - setDraftName(""); - setDraftDescription(""); - setIsCreateOpen(false); - } catch (error) { - setCreateErrorMessage( - error instanceof Error ? error.message : "Failed to create stream.", - ); - } - } - - async function handleCreateForum(event: React.FormEvent) { - event.preventDefault(); - - const name = forumDraftName.trim(); - const description = forumDraftDescription.trim(); - if (!name) { - return; - } - - setForumCreateErrorMessage(undefined); - - try { - await onCreateForum({ - name, - description: description || undefined, - }); - - setForumDraftName(""); - setForumDraftDescription(""); - setIsForumCreateOpen(false); - } catch (error) { - setForumCreateErrorMessage( - error instanceof Error ? error.message : "Failed to create forum.", - ); - } - } - - React.useEffect(() => { - if (!isForumCreateOpen) { - return; - } - - forumCreateInputRef.current?.focus(); - }, [isForumCreateOpen]); - function handleDragPointerDown(e: React.PointerEvent) { if (e.button !== 0) return; const target = e.target as HTMLElement; @@ -549,86 +505,47 @@ export function AppSidebar({ {!isLoading ? ( <> - { - setCreateErrorMessage(undefined); - setDraftName(""); - setDraftDescription(""); - setIsCreateOpen(false); - }} - onChangeDescription={(value) => { - setCreateErrorMessage(undefined); - setDraftDescription(value); - }} - onChangeName={(value) => { - setCreateErrorMessage(undefined); - setDraftName(value); - }} - onCreateChannel={(event) => { - void handleCreateChannel(event); - }} + listTestId="stream-list" + namePlaceholder="release-notes" + onBrowse={onOpenBrowseChannels} onSelectChannel={onSelectChannel} - onToggleCreate={() => { - setCreateErrorMessage(undefined); - setIsCreateOpen((current) => !current); - }} selectedChannelId={selectedChannelId} + title="Channels" unreadChannelIds={unreadChannelIds} /> - - - - - Browse channels - - - - { - setForumCreateErrorMessage(undefined); - setForumDraftName(""); - setForumDraftDescription(""); - setIsForumCreateOpen(false); - }} - onChangeDescription={(value) => { - setForumCreateErrorMessage(undefined); - setForumDraftDescription(value); - }} - onChangeName={(value) => { - setForumCreateErrorMessage(undefined); - setForumDraftName(value); - }} - onCreateForum={(event) => { - void handleCreateForum(event); - }} + listTestId="forum-list" + namePlaceholder="design-discussions" + onBrowse={onOpenBrowseForums} onSelectChannel={onSelectChannel} - onToggleCreate={() => { - setForumCreateErrorMessage(undefined); - setIsForumCreateOpen((current) => !current); - }} selectedChannelId={selectedChannelId} + title="Forums" unreadChannelIds={unreadChannelIds} /> } dmParticipantsByChannelId={dmParticipantsByChannelId} - emptyState="No direct messages yet." isActiveChannel={selectedView === "channel"} items={directMessages} channelLabels={dmChannelLabels} @@ -661,12 +577,6 @@ export function AppSidebar({ ) : null} - {!isLoading && channels.length === 0 ? ( -
- No channels available yet. -
- ) : null} - {errorMessage ? (
{errorMessage}