"use client"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@multica/ui/lib/utils"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { AppLink, useNavigation } from "../navigation"; import { HelpLauncher } from "./help-launcher"; import { JoinDiscordCard } from "./join-discord-card"; 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 { ChevronDown, ChevronRight, LogOut, Plus, Check, SquarePen, X, } 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 { CappedNumberFlow } from "@multica/ui/components/ui/number-flow"; import { StatusIcon } from "../issues/components/status-icon"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { openCreateIssueWithPreference } from "@multica/core/issues/stores/create-mode-store"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, 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 { useAuthStore } from "@multica/core/auth"; import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/paths"; import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries"; import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries"; import { chatSessionsOptions } from "@multica/core/chat/queries"; import { countUnreadChatMessages } from "@multica/core/chat/unread"; import { useChatStore } from "@multica/core/chat"; import { api, ApiError } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { useConfigStore } from "@multica/core/config"; import { pinListOptions } from "@multica/core/pins/queries"; import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations"; import { issueDetailOptions } from "@multica/core/issues/queries"; import { projectDetailOptions } from "@multica/core/projects/queries"; import type { PinnedItem } from "@multica/core/types"; import { useLogout } from "../auth"; import { ProjectIcon } from "../projects/components/project-icon"; import { routeIconForPath } from "./route-icon-components"; import { useT } from "../i18n"; import { useShortcut, } from "@multica/core/shortcuts"; import { ShortcutKeycaps } from "../common/shortcut-keycaps"; import { useAppForeground } from "../common/use-app-foreground"; // Top-level nav items stay active when the user is on a child route // (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep // strict equality elsewhere — a pinned project shouldn't highlight on // sub-pages of itself. function isNavActive(pathname: string, href: string): boolean { return pathname === href || pathname.startsWith(href + "/"); } // Stable empty arrays for query defaults. Using an inline `= []` default on // `useQuery` creates a new array reference on every render when `data` is // undefined (e.g. query disabled or loading) — which in turn breaks any // `useEffect`/`useMemo` that depends on the value, and can trigger infinite // re-render loops when the effect itself calls `setState`. const EMPTY_PINS: PinnedItem[] = []; const EMPTY_WORKSPACES: Awaited> = []; const EMPTY_INVITATIONS: Awaited> = []; const EMPTY_INBOX: Awaited> = []; const EMPTY_INBOX_SUMMARY: Awaited> = []; // 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" | "chat" | "myIssues" | "issues" | "projects" | "autopilots" | "agents" | "squads" | "usage" | "runtimes" | "skills" | "settings"; // Static schema (key only) — labels resolved at render via useT("layout"), // icons derived from the destination path via routeIconForPath. type NavLabelKey = | "inbox" | "chat" | "my_issues" | "issues" | "projects" | "autopilots" | "agents" | "squads" | "usage" | "runtimes" | "skills" | "settings"; // Nav icons are NOT declared here: they are derived from each item's // destination path at render time, so the sidebar and the desktop tab bar // always agree. See route-icon-components.tsx. const personalNav: { key: NavKey; labelKey: NavLabelKey }[] = [ { key: "inbox", labelKey: "inbox" }, { key: "chat", labelKey: "chat" }, { key: "myIssues", labelKey: "my_issues" }, ]; const workspaceNav: { key: NavKey; labelKey: NavLabelKey }[] = [ { key: "issues", labelKey: "issues" }, { key: "projects", labelKey: "projects" }, { key: "autopilots", labelKey: "autopilots" }, { key: "agents", labelKey: "agents" }, { key: "squads", labelKey: "squads" }, { key: "usage", labelKey: "usage" }, ]; const configureNav: { key: NavKey; labelKey: NavLabelKey }[] = [ { key: "runtimes", labelKey: "runtimes" }, { key: "skills", labelKey: "skills" }, { key: "settings", labelKey: "settings" }, ]; function DraftDot() { const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description)); if (!hasDraft) return null; return ; } /** * Presentational pin row. The `label` and `iconNode` are computed by the * parent `PinRow` from cached issue / project detail queries — keeping * this component dumb means the dnd-kit / navigation wiring lives in * one place and the data flow is explicit. */ function SortablePinItem({ pin, href, pathname, onUnpin, label, iconNode, }: { pin: PinnedItem; href: string; pathname: string; onUnpin: () => void; label: string; iconNode: React.ReactNode; }) { const { t } = useT("layout"); 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; 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", )} > {iconNode} {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(); }} > {t(($) => $.sidebar.unpin_tooltip)} ); } /** * Smart wrapper that resolves a pin's display data (label + status/icon) * from the issue / project detail query cache. Both queries are declared * unconditionally with `enabled` gates so the hook order stays stable * regardless of `pin.item_type`. * * Loading: render a flat skeleton so the sidebar height doesn't jump. * Missing (deleted item / 404): render nothing — the row hides itself * until the user unpins manually or a server-side cascade catches up. */ function PinRow({ pin, href, pathname, onUnpin, wsId, }: { pin: PinnedItem; href: string; pathname: string; onUnpin: () => void; wsId: string; }) { const isIssue = pin.item_type === "issue"; const issueQuery = useQuery({ ...issueDetailOptions(wsId, pin.item_id), enabled: isIssue, }); const projectQuery = useQuery({ ...projectDetailOptions(wsId, pin.item_id), enabled: !isIssue, }); const triggeredRef = useRef(false); useEffect(() => { const err = isIssue ? issueQuery.error : projectQuery.error; if (err instanceof ApiError && err.status === 404 && !triggeredRef.current) { triggeredRef.current = true; onUnpin(); } }, [isIssue, issueQuery.error, onUnpin, projectQuery.error]); if (isIssue) { if (issueQuery.isPending) return ; if (issueQuery.isError || !issueQuery.data) return null; const issue = issueQuery.data; const label = issue.title; const iconNode = ( /* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */ ); return ( ); } if (projectQuery.isPending) return ; if (projectQuery.isError || !projectQuery.data) return null; const project = projectQuery.data; const iconNode = ; return ( ); } function PinSkeleton() { return (
); } 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 { t } = useT("layout"); 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 = EMPTY_WORKSPACES } = useQuery(workspaceListOptions()); const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions()); const workspaceCreationDisabled = useConfigStore((s) => s.workspaceCreationDisabled); const wsId = workspace?.id; const { data: inboxItems = EMPTY_INBOX } = 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], ); // Chat tab unread badge: IM-style total of unread *messages* across chat // threads (countUnreadChatMessages is the shared definition — mobile's tab // badge derives from the same function, keeping the platforms in agreement). const { data: chatSessions = [] } = useQuery({ ...chatSessionsOptions(wsId ?? ""), enabled: !!wsId, }); // The session the user is reading right now must not count: the thread list // renders its row badge as 0 (auto mark-read is about to clear it), and a // reply landing in the open conversation would otherwise flash a sidebar // count with no matching row. "Reading right now" = a session is active, a // chat surface is actually showing it (chat page route or the floating // window), AND the app is in the foreground. When the app is backgrounded, // auto mark-read is suppressed (MUL-4485) so the reply stays unread — the // badge must count it, or the notification is silently eaten while the user // is away. A remembered selection while both surfaces are closed also still // counts, for the same reason. const activeChatSessionId = useChatStore((s) => s.activeSessionId); const floatingChatOpen = useChatStore((s) => s.isOpen); const appForeground = useAppForeground(); const chatHref = p.chat(); const viewedChatSessionId = appForeground && (floatingChatOpen || isNavActive(pathname, chatHref)) ? activeChatSessionId : null; const chatUnreadCount = React.useMemo( () => countUnreadChatMessages(chatSessions, viewedChatSessionId), [chatSessions, viewedChatSessionId], ); // Cross-workspace unread summary backs the workspace-switcher dot. One // shared cache entry across workspaces; gated on an active workspace since // the endpoint resolves through the workspace-member middleware. const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({ ...inboxUnreadSummaryOptions(), enabled: !!wsId, }); const otherWorkspaceUnread = React.useMemo( () => hasOtherWorkspaceUnread(unreadSummary, wsId), [unreadSummary, wsId], ); // Which workspaces have unread, so the switcher dropdown can point at the // specific one(s) rather than just the aggregate avatar dot. const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]); const { data: pinnedItems = EMPTY_PINS } = useQuery({ ...pinListOptions(wsId ?? "", userId ?? ""), enabled: !!wsId && !!userId, }); const deletePin = useDeletePin(); const reorderPins = useReorderPins(); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const sidebarScrollRef = useRef(null); const sidebarFadeStyle = useScrollFade(sidebarScrollRef, 24); const getPinHref = useCallback( (pin: PinnedItem) => (pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id)), [p], ); // 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 [localPinnedWsId, setLocalPinnedWsId] = useState(wsId ?? null); const isDraggingRef = useRef(false); useEffect(() => { if (!isDraggingRef.current) { setLocalPinned(pinnedItems); } }, [pinnedItems]); useEffect(() => { setLocalPinnedWsId(wsId ?? null); }, [wsId]); const visiblePinned = localPinnedWsId === (wsId ?? null) ? localPinned : EMPTY_PINS; const isActivePinnedRoute = visiblePinned.some((pin) => pathname === getPinHref(pin)); 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() }); }, }); const createIssueShortcut = useShortcut("createIssue"); return ( {topSlot} {/* Workspace Switcher */} {/* Shared brand dot: a pending invitation OR another workspace with unread inbox items. The active workspace's own unread stays on the Inbox nav count (below), so it is deliberately excluded here. */} {(myInvitations.length > 0 || otherWorkspaceUnread) && ( )} {workspace?.name ?? "Multica"} } />

{user?.name}

{user?.email}

{t(($) => $.sidebar.workspaces_label)} {workspaces.map((ws) => ( } > {ws.name} {/* Points at the specific workspace holding unread inbox items. Sits in the same right-edge slot as the active-workspace check; the active workspace is excluded (its unread is the Inbox nav count), so dot and check never collide on one row. */} {ws.id !== workspace?.id && unreadWsIds.has(ws.id) && ( )} {ws.id === workspace?.id && ( )} ))} {!workspaceCreationDisabled && ( useModalStore.getState().open("create-workspace") } > {t(($) => $.sidebar.create_workspace)} )} {myInvitations.length > 0 && ( <> {t(($) => $.sidebar.pending_invitations_label)} {myInvitations.map((inv) => (
{inv.workspace_name ?? t(($) => $.sidebar.invitation_workspace_fallback)}
))}
)} {t(($) => $.sidebar.log_out)}
{searchSlot && ( {searchSlot} )} openCreateIssueWithPreference()} > {t(($) => $.sidebar.new_issue)} {createIssueShortcut ? ( ) : null}
{/* Navigation */} {personalNav.map((item) => { const href = p[item.key](); const Icon = routeIconForPath(href); const isActive = isNavActive(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" > {t(($) => $.nav[item.labelKey])} {item.key === "inbox" && unreadCount > 0 && ( )} {item.key === "chat" && chatUnreadCount > 0 && ( )} ); })} {visiblePinned.length > 0 && ( } className="group/trigger cursor-pointer hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground" > {t(($) => $.sidebar.pinned_label)} {visiblePinned.length} p.id)} strategy={verticalListSortingStrategy}> {visiblePinned.map((pin: PinnedItem) => ( deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })} wsId={wsId ?? ""} /> ))} )} {t(($) => $.sidebar.workspace_group)} {workspaceNav.map((item) => { const href = p[item.key](); const Icon = routeIconForPath(href); const isActive = !isActivePinnedRoute && isNavActive(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" > {t(($) => $.nav[item.labelKey])} ); })} {t(($) => $.sidebar.configure_group)} {configureNav.map((item) => { const href = p[item.key](); const Icon = routeIconForPath(href); const isActive = isNavActive(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" > {t(($) => $.nav[item.labelKey])} ); })}
); }