/* Copyright 2026 Marimo. All rights reserved. */
import { useAtom, useAtomValue } from "jotai";
import { MessageCircleQuestionIcon } from "lucide-react";
import type React from "react";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo } from "react";
import { ReorderableList } from "@/components/ui/reorderable-list";
import { Tooltip } from "@/components/ui/tooltip";
import {
cellErrorCount,
notebookQueuedOrRunningCountAtom,
} from "@/core/cells/cells";
import { capabilitiesAtom } from "@/core/config/capabilities";
import { cn } from "@/utils/cn";
import { FeedbackButton } from "../components/feedback-button";
import { panelLayoutAtom, useChromeActions, useChromeState } from "../state";
import {
isPanelHidden,
PANEL_MAP,
PANELS,
type PanelDescriptor,
} from "../types";
export const Sidebar: React.FC = () => {
const { selectedPanel, selectedDeveloperPanelTab, isSidebarOpen } =
useChromeState();
const { toggleApplication, openApplication, setIsSidebarOpen } =
useChromeActions();
const [panelLayout, setPanelLayout] = useAtom(panelLayoutAtom);
// Subscribe to capabilities to re-render when they change
const capabilities = useAtomValue(capabilitiesAtom);
const renderIcon = ({ Icon }: PanelDescriptor, className?: string) => {
return ;
};
// Get panels available for sidebar context menu
// Only show panels that are NOT in the developer panel
const availableSidebarPanels = useMemo(() => {
const devPanelIds = new Set(panelLayout.developerPanel);
return PANELS.filter((p) => {
if (isPanelHidden(p, capabilities)) {
return false;
}
// Exclude panels that are in the developer panel
if (devPanelIds.has(p.type)) {
return false;
}
return true;
});
}, [panelLayout.developerPanel, capabilities]);
// Convert current sidebar items to PanelDescriptors
// Filter out hidden panels (e.g., when capability is not available)
const sidebarItems = useMemo(() => {
return panelLayout.sidebar.flatMap((id) => {
const panel = PANEL_MAP.get(id);
if (!panel || isPanelHidden(panel, capabilities)) {
return [];
}
return [panel];
});
}, [panelLayout.sidebar, capabilities]);
const handleSetSidebarItems = (items: PanelDescriptor[]) => {
setPanelLayout((prev) => ({
...prev,
sidebar: items.map((item) => item.type),
}));
};
const handleReceive = (item: PanelDescriptor, fromListId: string) => {
// Remove from the source list
if (fromListId === "developer-panel") {
setPanelLayout((prev) => ({
...prev,
developerPanel: prev.developerPanel.filter((id) => id !== item.type),
}));
// If the moved item was selected in dev panel, select the first remaining item
if (selectedDeveloperPanelTab === item.type) {
const remainingDevPanels = panelLayout.developerPanel.filter(
(id) => id !== item.type,
);
if (remainingDevPanels.length > 0) {
openApplication(remainingDevPanels[0]);
}
}
}
// Select the dropped item in sidebar
toggleApplication(item.type);
};
// Auto-correct sidebar selection when the selected panel is no longer available
useEffect(() => {
if (!isSidebarOpen) {
return;
}
const isSelectionValid = sidebarItems.some((p) => p.type === selectedPanel);
if (!isSelectionValid) {
if (sidebarItems.length > 0) {
openApplication(sidebarItems[0].type);
} else {
setIsSidebarOpen(false);
}
}
}, [
isSidebarOpen,
sidebarItems,
selectedPanel,
openApplication,
setIsSidebarOpen,
]);
return (
value={sidebarItems}
setValue={handleSetSidebarItems}
getKey={(p) => p.type}
availableItems={availableSidebarPanels}
crossListDrag={{
dragType: "panels",
listId: "sidebar",
onReceive: handleReceive,
}}
getItemLabel={(panel) => (
{renderIcon(panel, "h-4 w-4 text-muted-foreground")}
{panel.label}
)}
ariaLabel="Sidebar panels"
className="flex flex-col gap-0"
minItems={0}
onAction={(panel) => toggleApplication(panel.type)}
renderItem={(panel) => (
{panel.type === "errors" ? (
) : (
renderIcon(panel)
)}
)}
/>
);
};
const ErrorPanelIcon: React.FC<{ Icon: PanelDescriptor["Icon"] }> = ({
Icon,
}) => {
const errorCount = useAtomValue(cellErrorCount);
return (
0 && "text-destructive")} />
);
};
const QueuedOrRunningStack = () => {
const count = useAtomValue(notebookQueuedOrRunningCountAtom);
return (
0 ? (
{count} cell{count > 1 ? "s" : ""} queued or running
) : (
"No cells queued or running"
)
}
side="right"
delayDuration={200}
>
{Array.from({ length: count }).map((_, index) => (
))}
);
};
const SidebarItem: React.FC<
PropsWithChildren<{
selected: boolean;
tooltip: React.ReactNode;
className?: string;
onClick?: () => void;
}>
> = ({ children, tooltip, selected, className, onClick }) => {
const itemClassName = cn(
"flex items-center p-2 text-sm mx-px shadow-inset font-mono rounded",
!selected && "hover:bg-(--sage-3)",
selected && "bg-(--sage-4)",
className,
);
// Render as div when not clickable (e.g., inside ReorderableList)
// This avoids nested interactive elements which break react-aria's drag behavior
const content = onClick ? (
) : (
{children}
);
return (
{content}
);
};