"use client"; import React, { useCallback, useEffect, useRef } from "react"; import { cn } from "@multica/ui/lib/utils"; import { AppLink, useNavigation } from "../navigation"; import { DndContext, PointerSensor, useSensor, useSensors, closestCenter, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Inbox, ListTodo, Bot, Monitor, ChevronDown, Settings, LogOut, Plus, Check, BookOpenText, SquarePen, CircleUser, FolderKanban, PinOff, Zap, } from "lucide-react"; import { WorkspaceAvatar } from "../workspace/workspace-avatar"; import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarFooter, SidebarMenu, SidebarMenuButton, SidebarMenuAction, SidebarMenuItem, SidebarRail, } from "@multica/ui/components/ui/sidebar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@multica/ui/components/ui/popover"; import { useAuthStore } from "@multica/core/auth"; import { useWorkspaceStore } from "@multica/core/workspace"; import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries"; import { api } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks"; import { pinListOptions } from "@multica/core/pins/queries"; import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations"; import type { PinnedItem } from "@multica/core/types"; const personalNav = [ { href: "/inbox", label: "Inbox", icon: Inbox }, { href: "/my-issues", label: "My Issues", icon: CircleUser }, ]; const workspaceNav = [ { href: "/issues", label: "Issues", icon: ListTodo }, { href: "/projects", label: "Projects", icon: FolderKanban }, { href: "/autopilots", label: "Autopilot", icon: Zap }, { href: "/agents", label: "Agents", icon: Bot }, ]; const configureNav = [ { href: "/runtimes", label: "Runtimes", icon: Monitor }, { href: "/skills", label: "Skills", icon: BookOpenText }, { href: "/settings", label: "Settings", icon: Settings }, ]; function DraftDot() { const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description)); if (!hasDraft) return null; return ; } function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id }); const wasDragged = useRef(false); useEffect(() => { if (isDragging) wasDragged.current = true; }, [isDragging]); const style = { transform: CSS.Transform.toString(transform), transition }; const href = pin.item_type === "issue" ? `/issues/${pin.item_id}` : `/projects/${pin.item_id}`; const isActive = pathname === href; const label = pin.item_type === "issue" && pin.identifier ? `${pin.identifier} ${pin.title}` : pin.title; return ( } onClick={(event) => { if (wasDragged.current) { wasDragged.current = false; event.preventDefault(); return; } }} className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" > {pin.item_type === "issue" ? ( ) : ( )} {label} { event.preventDefault(); event.stopPropagation(); onUnpin(); }} > ); } interface AppSidebarProps { /** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */ topSlot?: React.ReactNode; /** Rendered in the header between workspace switcher and new-issue button (e.g. search trigger) */ searchSlot?: React.ReactNode; /** Extra className for SidebarHeader */ headerClassName?: string; /** Extra style for SidebarHeader */ headerStyle?: React.CSSProperties; } export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) { const { pathname, push } = useNavigation(); const user = useAuthStore((s) => s.user); const userId = useAuthStore((s) => s.user?.id); const authLogout = useAuthStore((s) => s.logout); const workspace = useWorkspaceStore((s) => s.workspace); const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace); const { data: workspaces = [] } = useQuery(workspaceListOptions()); const { data: myInvitations = [] } = useQuery(myInvitationListOptions()); const wsId = workspace?.id; const { data: inboxItems = [] } = useQuery({ queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"], queryFn: () => api.listInbox(), enabled: !!wsId, }); const unreadCount = React.useMemo( () => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length, [inboxItems], ); const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId); const { data: pinnedItems = [] } = useQuery({ ...pinListOptions(wsId ?? "", userId ?? ""), enabled: !!wsId && !!userId, }); const deletePin = useDeletePin(); const reorderPins = useReorderPins(); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = pinnedItems.findIndex((p) => p.id === active.id); const newIndex = pinnedItems.findIndex((p) => p.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(pinnedItems, oldIndex, newIndex); reorderPins.mutate(reordered); }, [pinnedItems, reorderPins], ); const queryClient = useQueryClient(); const acceptInvitationMut = useMutation({ mutationFn: (id: string) => api.acceptInvitation(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); queryClient.invalidateQueries({ queryKey: workspaceKeys.list() }); }, }); const declineInvitationMut = useMutation({ mutationFn: (id: string) => api.declineInvitation(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); }, }); const logout = () => { queryClient.clear(); authLogout(); useWorkspaceStore.getState().clearWorkspace(); }; // Global "C" shortcut to open create-issue modal (like Linear) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) { const tag = (e.target as HTMLElement)?.tagName; const isEditable = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (e.target as HTMLElement)?.isContentEditable; if (isEditable) return; if (useModalStore.getState().modal) return; e.preventDefault(); // Auto-fill project when on a project detail page const projectMatch = pathname.match(/^\/projects\/([^/]+)$/); const data = projectMatch ? { project_id: projectMatch[1] } : undefined; useModalStore.getState().open("create-issue", data); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [pathname]); return ( {topSlot} {/* Workspace Switcher */} {workspace?.name ?? "Multica"} } /> {user?.email} Workspaces {workspaces.map((ws) => ( { if (ws.id !== workspace?.id) { push("/issues"); switchWorkspace(ws); } }} > {ws.name} {ws.id === workspace?.id && ( )} ))} useModalStore.getState().open("create-workspace") } > Create workspace {myInvitations.length > 0 && ( <> Pending invitations {myInvitations.map((inv) => ( {inv.workspace_name ?? "Workspace"} { e.stopPropagation(); acceptInvitationMut.mutate(inv.id); }} > Join { e.stopPropagation(); declineInvitationMut.mutate(inv.id); }} > Decline ))} > )} Log out {searchSlot && ( {searchSlot} )} useModalStore.getState().open("create-issue")} > New Issue C {/* Navigation */} {personalNav.map((item) => { const isActive = pathname === item.href; return ( } className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" > {item.label} {item.label === "Inbox" && unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} ); })} {pinnedItems.length > 0 && ( Pinned p.id)} strategy={verticalListSortingStrategy}> {pinnedItems.map((pin: PinnedItem) => ( deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })} /> ))} )} Workspace {workspaceNav.map((item) => { const isActive = pathname === item.href; return ( } className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" > {item.label} ); })} Configure {configureNav.map((item) => { const isActive = pathname === item.href; return ( } className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground" > {item.label} {item.label === "Runtimes" && hasRuntimeUpdates && ( )} ); })} {user?.name} {user?.email} {user?.name} {user?.email} Log out ); }
{user?.name}
{user?.email}