import { useStore } from '@nanostores/react' import { Children, type CSSProperties, type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { RowButton } from '@/components/ui/row-button' import { Switch } from '@/components/ui/switch' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' import { $paneHeightOverride, $paneState, $paneWidthOverride, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' // Monospace capability chip (tool name, transport, …). Shared by the Skills // and MCP tabs so the pill reads identically everywhere. export function ToolChip({ children, title }: { children: ReactNode; title?: string }) { return ( {children} ) } // Master–detail page scaffolding (14rem rail, p-2, centered max-w-2xl detail): // dense uniform rows on the left, roomy inspector on the right. Shared by the // Capabilities and Messaging pages — pages bring their own row/detail content // (CapRow here is the toggle-row flavor; Messaging has its own avatar rows). // `pane` docks a full-bleed work surface (editor, log viewer, terminal) below // the whole master–detail grid — the app's bottom-pane pattern, page-local. // The wide-rail track shared by every Capabilities tab (skills/tools/mcp) so // the three read as one page. Exported for pages that build their own grid // (the MCP tab's cursor-driven layout) but must stay in step. // `--md-split` is the drag override slot: unset it falls back to the declared // track, so grids without a resize sash render exactly as before. export const MASTER_DETAIL_WIDE_COLS = 'sm:grid-cols-[minmax(0,var(--md-split,0.75fr))_minmax(0,1fr)]' // Column-seam drag clamps: the rail can't shrink below a readable row, the // detail keeps enough room for its centered column. const SPLIT_MIN_LEFT_PX = 180 const SPLIT_MIN_RIGHT_PX = 320 // `split="wide"` gives list-heavy pages a rail that shares the page with a // sparse detail (skills/tools/mcp); the default 14rem rail suits pages whose // detail carries the weight (messaging). A `resizeId` turns the column seam // into a drag sash: the rail width persists in the pane store under that id // (same store as the terminal/editor panes), double-click resets to default. export function MasterDetail({ children, pane, resizeId, split = 'rail' }: { children: ReactNode pane?: ReactNode /** Pane-store key — when set, the seam between the two columns becomes a * drag-resizable sash and the rail width persists under this id. */ resizeId?: string split?: 'rail' | 'wide' }) { const gridRef = useRef(null) // Unconditional hook (rules of hooks) — the '' atom is inert when no id. const override = useStore($paneWidthOverride(resizeId ?? '')) const [dragging, setDragging] = useState(false) const startSplitDrag = (event: ReactPointerEvent) => { const grid = gridRef.current if (!resizeId || !grid || event.button !== 0) { return } event.preventDefault() const startX = event.clientX const startWidth = (grid.children[0] as HTMLElement).getBoundingClientRect().width const max = Math.max(SPLIT_MIN_LEFT_PX, grid.getBoundingClientRect().width - SPLIT_MIN_RIGHT_PX) setDragging(true) const onMove = (move: globalThis.PointerEvent) => { setPaneWidthOverride( resizeId, Math.round(Math.min(max, Math.max(SPLIT_MIN_LEFT_PX, startWidth + (move.clientX - startX)))) ) } const onUp = () => { window.removeEventListener('pointermove', onMove) setDragging(false) } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp, { once: true }) } // With a sash the detail side gets a relative wrapper so the seam handle can // sit on the boundary itself (junction-owned, like the shell's sashes). const [list, ...rest] = Children.toArray(children) return (
{resizeId ? ( <> {list}
setPaneWidthOverride(resizeId, undefined)} onPointerDown={startSplitDrag} >
{rest}
) : ( children )}
{pane}
) } export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) { return ( ) } // `footer` pins one quiet caption below the scroll (e.g. "changes apply to // new sessions") so per-item detail components never repeat it themselves. // `actionBar` pins a real control row (save/toggle) below the scroll instead. export function DetailColumn({ actionBar, children, footer }: { actionBar?: ReactNode children: ReactNode footer?: ReactNode }) { return (
{children}
{footer && (
{footer}
)} {actionBar && (
{actionBar}
)}
) } // Full-bleed docked bottom pane: title strip + actions + close, drag-resizable // on its top edge like every other pane (height persisted through the same // pane-state store the terminal uses). No min height — drag (or the chevron) // collapses it down to just the header. Content swaps freely: JSON editor // today, stdio/log viewers tomorrow. const DETAIL_PANE_DEFAULT_BODY_PX = 288 const DETAIL_PANE_MAX_VH = 0.7 const DETAIL_PANE_COLLAPSED_PX = 4 // Ghost icon-button on the kebab-trigger scale (pane headers, list-strip menu, // per-server MCP actions, JSON editor format button). MUST stay a class string // (not a CSS @utility): the leading `size-5` is what tailwind-merge uses to // strip {onClose && ( )}
{children}
) } // One-line control strip pinned above the list: sort/primary action on the // left, overflow kebab on the right. export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) { return (
{left}
{right}
) } export interface ListStripMenuItem { disabled?: boolean label: string onSelect: () => void } export interface ListStripMenuToggle { checked: boolean disabled?: boolean label: string onToggle: (checked: boolean) => void } // Overflow kebab for list-wide actions. `toggle` renders as the first row — // one label + switch line covering enable-all/disable-all (checked = every // visible item on; mixed reads as off so one flip always means "all on"). export function ListStripMenu({ items = [], label, toggle }: { items?: ListStripMenuItem[] label: string toggle?: ListStripMenuToggle }) { return ( {toggle && ( { // Keep the menu open so the switch is seen flipping. event.preventDefault() toggle.onToggle(!toggle.checked) }} > {toggle.label} )} {items.map(item => ( {item.label} ))} ) } export function ListStripButton({ active, children, disabled, onClick }: { active?: boolean children: ReactNode disabled?: boolean onClick: () => void }) { return ( ) } interface CapRowProps { /** Rendered in the switch slot instead of the Switch (e.g. an Install * button for not-yet-installed catalog rows). */ action?: ReactNode active: boolean busy?: boolean enabled: boolean meta?: ReactNode onSelect: () => void onToggle?: (checked: boolean) => void rowId?: string /** Second line under the name (category, description, status). Rows grow to h-11. */ subtitle?: ReactNode title: string toggleLabel?: string } // The one row used by all three lists. Fixed height, always-visible switch — // state reads from the switch + dimmed title, toggling never requires // selecting first. Off rows dim; the switch itself dims when off. export function CapRow({ action, active, busy, enabled, meta, onSelect, onToggle, rowId, subtitle, title, toggleLabel }: CapRowProps) { return (
{title} {subtitle != null && ( {typeof subtitle === 'string' ? {subtitle} : subtitle} )} {meta != null && ( {meta} )} {action != null ? ( {action} ) : ( )}
) }