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 (
)
}
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 && (
)}
)
}
// 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