"use client"; import React, { useCallback, useEffect, useRef, useState } 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, ChevronRight, Settings, LogOut, Plus, Check, BookOpenText, SquarePen, CircleUser, FolderKanban, X, Zap, } from "lucide-react"; import { WorkspaceAvatar } from "../workspace/workspace-avatar"; import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible"; import { StatusIcon } from "../issues/components/status-icon"; import type { IssueStatus } from "@multica/core/types"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarFooter, SidebarMenu, SidebarMenuButton, 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 { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/paths"; 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"; import { useLogout } from "../auth"; // Nav items reference WorkspacePaths method names so they can be resolved // against the current workspace slug at render time (see AppSidebar body). // Only parameterless paths are valid nav destinations. type NavKey = | "inbox" | "myIssues" | "issues" | "projects" | "autopilots" | "agents" | "runtimes" | "skills" | "settings"; const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [ { key: "inbox", label: "Inbox", icon: Inbox }, { key: "myIssues", label: "My Issues", icon: CircleUser }, ]; const workspaceNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [ { key: "issues", label: "Issues", icon: ListTodo }, { key: "projects", label: "Projects", icon: FolderKanban }, { key: "autopilots", label: "Autopilot", icon: Zap }, { key: "agents", label: "Agents", icon: Bot }, ]; const configureNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [ { key: "runtimes", label: "Runtimes", icon: Monitor }, { key: "skills", label: "Skills", icon: BookOpenText }, { key: "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, href, pathname, onUnpin }: { pin: PinnedItem; href: string; 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 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={cn( "text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground", isDragging && "pointer-events-none", )} > {pin.item_type === "issue" && pin.status ? ( /* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */ ) : ( {pin.icon || "📁"} )} {label} } className="hidden size-2.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground group-hover/pin:flex hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); onUnpin(); }} > Unpin ); } 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 logout = useLogout(); const workspace = useCurrentWorkspace(); const p = useWorkspacePaths(); 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 } })); // Local presentational copy of pinnedItems for drop-animation stability. // Follows TQ at rest; frozen during a drag gesture so a mid-drag cache // write (our own optimistic update, or a WS refetch) cannot reorder the // DOM under dnd-kit while its drop animation is still interpolating. const [localPinned, setLocalPinned] = useState(pinnedItems); const isDraggingRef = useRef(false); useEffect(() => { if (!isDraggingRef.current) { setLocalPinned(pinnedItems); } }, [pinnedItems]); const handleDragStart = useCallback(() => { isDraggingRef.current = true; }, []); const handleDragEnd = useCallback( (event: DragEndEvent) => { isDraggingRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = localPinned.findIndex((p) => p.id === active.id); const newIndex = localPinned.findIndex((p) => p.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(localPinned, oldIndex, newIndex); setLocalPinned(reordered); reorderPins.mutate(reordered); }, [localPinned, reorderPins], ); const queryClient = useQueryClient(); const acceptInvitationMut = useMutation({ mutationFn: (id: string) => api.acceptInvitation(id), // After accepting an invitation, navigate INTO the newly-joined workspace. // Otherwise the user stays on their current workspace and just sees the // new one appear in the dropdown — silent and confusing (this is MUL-820). onSuccess: async (_, invitationId) => { const invitation = myInvitations.find((i) => i.id === invitationId); queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); // staleTime: 0 forces a real network fetch — we need the joined workspace // in the list before we can resolve its slug for navigation. const list = await queryClient.fetchQuery({ ...workspaceListOptions(), staleTime: 0, }); const joined = invitation ? list.find((w) => w.id === invitation.workspace_id) : null; if (joined) { push(paths.workspace(joined.slug).issues()); } }, }); const declineInvitationMut = useMutation({ mutationFn: (id: string) => api.declineInvitation(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); }, }); // 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) => ( } > {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 href = p[item.key](); const isActive = pathname === 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} )} ); })} {localPinned.length > 0 && ( } className="group/trigger cursor-pointer hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground" > Pinned {localPinned.length} p.id)} strategy={verticalListSortingStrategy}> {localPinned.map((pin: PinnedItem) => ( deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })} /> ))} )} Workspace {workspaceNav.map((item) => { const href = p[item.key](); const isActive = pathname === 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 href = p[item.key](); const isActive = pathname === 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}