Spaces:
Paused
Paused
| import { X } from 'lucide-react'; | |
| import { Fragment, type ReactNode } from 'react'; | |
| import { Button } from '@/components/ui/button.tsx'; | |
| import { | |
| ResizableHandle, | |
| ResizablePanel, | |
| ResizablePanelGroup, | |
| } from '@/components/ui/resizable.tsx'; | |
| import { Separator } from '@/components/ui/separator'; | |
| /** | |
| * Identifier for a dockable panel. Used both as the React key and to | |
| * look its descriptor up in {@link PanelDockProps.panels}. | |
| */ | |
| export type PanelKey = 'plan' | 'mcp' | 'skill' | 'permission' | 'knowledge'; | |
| /** | |
| * The presentation of a single panel: header chrome plus its already | |
| * data-bound content node. Built by the owner (ChatViewport) so that | |
| * the dock itself stays free of any business/data dependency. | |
| */ | |
| export interface PanelDescriptor { | |
| /** Header title shown in the panel's chrome. */ | |
| title: ReactNode; | |
| /** Optional leading icon shown next to the title. */ | |
| icon?: ReactNode; | |
| /** | |
| * Optional header-bar actions rendered to the left of the close | |
| * button — typically `<Button variant="ghost" size="icon-sm">` items | |
| * that open popovers / dropdowns (e.g. a parameters popover that | |
| * would otherwise crowd the panel body). | |
| */ | |
| actions?: ReactNode; | |
| /** | |
| * The panel body. Constructed by the owner with live data | |
| * (e.g. `<TaskPanel tasksContext={...} />`) so it always reflects | |
| * the latest state on every owner re-render. | |
| */ | |
| content: ReactNode; | |
| } | |
| interface PanelDockProps { | |
| /** | |
| * Controlled layout state. Outer array = columns (laid out left to | |
| * right), inner array = the panels stacked top to bottom within a | |
| * column (max 2). The dock never mutates this; it only renders it. | |
| */ | |
| layout: PanelKey[][]; | |
| /** | |
| * Lookup from {@link PanelKey} to its {@link PanelDescriptor}. Only | |
| * needs to contain keys that may appear in {@link layout}. | |
| */ | |
| panels: Record<PanelKey, PanelDescriptor>; | |
| /** | |
| * Invoked when a panel's close button is clicked. The owner is | |
| * responsible for removing the key from {@link layout} (and dropping | |
| * the column if it becomes empty). | |
| * | |
| * @param key - The panel being closed. | |
| */ | |
| onClosePanel: (key: PanelKey) => void; | |
| } | |
| /** | |
| * Minimum width of a dock column. Keeps a panel's content (search box, | |
| * list items) from being squeezed unusably narrow. | |
| */ | |
| const COLUMN_MIN_WIDTH = '20rem'; | |
| /** | |
| * Minimum height of a single panel within a column. Guarantees the | |
| * title bar plus a sliver of content stays visible. | |
| */ | |
| const PANEL_MIN_HEIGHT = '6rem'; | |
| interface PanelProps { | |
| title: ReactNode; | |
| icon?: ReactNode; | |
| actions?: ReactNode; | |
| onClose: () => void; | |
| children: ReactNode; | |
| } | |
| /** | |
| * Generic panel chrome: a bordered container with a title bar (icon + | |
| * title + optional actions + close button) and a flexible body. Content | |
| * components are rendered as `children` and should not draw their own | |
| * header/border. | |
| * | |
| * @param title - Header title. | |
| * @param icon - Optional leading icon. | |
| * @param actions - Optional nodes rendered to the left of the close | |
| * button (e.g. a settings popover trigger). | |
| * @param onClose - Called when the close button is clicked. | |
| * @param children - The panel body. | |
| * @returns The panel chrome element. | |
| */ | |
| export const Panel = ({ title, icon, actions, onClose, children }: PanelProps) => { | |
| return ( | |
| <div className="flex flex-1 flex-col border rounded-sm h-full py-1 min-h-0"> | |
| <div className="flex items-center justify-between px-2"> | |
| <span className="flex items-center gap-x-1.5 text-sm"> | |
| {icon} | |
| {title} | |
| </span> | |
| <div className="flex items-center gap-x-0.5"> | |
| {actions} | |
| {actions ? ( | |
| <Separator | |
| orientation="vertical" | |
| className="mx-0.5 data-vertical:h-4 data-vertical:self-center" | |
| /> | |
| ) : null} | |
| <Button variant="ghost" size="icon-sm" onClick={onClose}> | |
| <X /> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="flex flex-1 flex-col min-h-0 overflow-auto px-2">{children}</div> | |
| </div> | |
| ); | |
| }; | |
| /** | |
| * A pure layout engine for the right-hand dockable panel area. | |
| * | |
| * Knows nothing about MCP / tasks / skills — it just arranges whatever | |
| * panels {@link PanelDockProps.layout} describes into a grid of | |
| * horizontally-resizable columns, each containing up to two | |
| * vertically-resizable rows. The panel bodies arrive pre-built via | |
| * {@link PanelDockProps.panels}. | |
| * | |
| * Rendered as a sibling of the chat panel inside the outer horizontal | |
| * `ResizablePanelGroup`, so it emits one `ResizablePanel` per column | |
| * (with a `ResizableHandle` between columns) to share that group. | |
| * | |
| * @param layout - Controlled column/panel arrangement. | |
| * @param panels - Descriptor lookup for each panel key. | |
| * @param onClosePanel - Close-request callback. | |
| * @returns The column fragment, or `null` when no panels are open. | |
| */ | |
| export const PanelDock = ({ layout, panels, onClosePanel }: PanelDockProps) => { | |
| if (layout.length === 0) return null; | |
| return ( | |
| <> | |
| {layout.map((column, colIndex) => ( | |
| <Fragment key={`col-${column.join('-')}`}> | |
| {colIndex > 0 && <ResizableHandle withHandle className="bg-transparent" />} | |
| <ResizablePanel className="p-1" minSize={COLUMN_MIN_WIDTH} defaultSize="22rem"> | |
| <ResizablePanelGroup orientation="vertical"> | |
| {column.map((key, rowIndex) => { | |
| const descriptor = panels[key]; | |
| if (!descriptor) return null; | |
| return ( | |
| <Fragment key={key}> | |
| {rowIndex > 0 && ( | |
| <ResizableHandle | |
| withHandle | |
| className="bg-transparent" | |
| /> | |
| )} | |
| <ResizablePanel className="py-1" minSize={PANEL_MIN_HEIGHT}> | |
| <Panel | |
| title={descriptor.title} | |
| icon={descriptor.icon} | |
| actions={descriptor.actions} | |
| onClose={() => onClosePanel(key)} | |
| > | |
| {descriptor.content} | |
| </Panel> | |
| </ResizablePanel> | |
| </Fragment> | |
| ); | |
| })} | |
| </ResizablePanelGroup> | |
| </ResizablePanel> | |
| </Fragment> | |
| ))} | |
| </> | |
| ); | |
| }; | |