507 lines
17 KiB
TypeScript
507 lines
17 KiB
TypeScript
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 (
|
||
<span
|
||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||
title={title}
|
||
>
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// 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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||
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 (
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<div
|
||
className={cn(
|
||
'grid min-h-0 flex-1 grid-cols-1',
|
||
split === 'wide' ? MASTER_DETAIL_WIDE_COLS : 'sm:grid-cols-[14rem_minmax(0,1fr)]'
|
||
)}
|
||
ref={gridRef}
|
||
style={override !== undefined ? ({ '--md-split': `${override}px` } as CSSProperties) : undefined}
|
||
>
|
||
{resizeId ? (
|
||
<>
|
||
{list}
|
||
<div className="relative grid min-h-0 min-w-0">
|
||
<div
|
||
className="group/vsash absolute inset-y-0 left-0 z-10 hidden w-1 -translate-x-1/2 cursor-col-resize sm:block"
|
||
onDoubleClick={() => setPaneWidthOverride(resizeId, undefined)}
|
||
onPointerDown={startSplitDrag}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors',
|
||
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/vsash:bg-(--ui-stroke-secondary)'
|
||
)}
|
||
/>
|
||
</div>
|
||
{rest}
|
||
</div>
|
||
</>
|
||
) : (
|
||
children
|
||
)}
|
||
</div>
|
||
{pane}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) {
|
||
return (
|
||
<aside className="flex min-h-0 flex-col p-2">
|
||
{header}
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">{children}</div>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
// `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 (
|
||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
|
||
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">{children}</div>
|
||
</div>
|
||
{footer && (
|
||
<div className="mx-auto w-full max-w-2xl shrink-0 px-5 pb-3 pt-1.5 text-right text-[0.65rem] text-muted-foreground/50">
|
||
{footer}
|
||
</div>
|
||
)}
|
||
{actionBar && (
|
||
<footer className="shrink-0 bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">{actionBar}</div>
|
||
</footer>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|
||
|
||
// 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 <Button size="icon">'s larger built-in size — a custom utility class
|
||
// isn't size-merge-aware, so Button's icon size would leak and blow it up.
|
||
// Compose extra state (data-[state=open], hover:text-destructive) with cn().
|
||
export const ICON_BUTTON =
|
||
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
|
||
|
||
export function DetailPane({
|
||
actions,
|
||
children,
|
||
defaultCollapsed = false,
|
||
defaultHeight = DETAIL_PANE_DEFAULT_BODY_PX,
|
||
id,
|
||
onClose,
|
||
title
|
||
}: {
|
||
actions?: ReactNode
|
||
children: ReactNode
|
||
/** Start collapsed to the header the first time this pane is ever shown.
|
||
* Only seeds when the id has no saved state — a later expand/collapse
|
||
* persists and wins, so it's "collapsed by default", not "always collapsed". */
|
||
defaultCollapsed?: boolean
|
||
/** Default body height in px (before any user resize). */
|
||
defaultHeight?: number
|
||
/** Pane-store key — height overrides persist under it. */
|
||
id: string
|
||
/** Omit for permanent panes (collapsible to the header, never removed). */
|
||
onClose?: () => void
|
||
title: ReactNode
|
||
}) {
|
||
const { t } = useI18n()
|
||
const override = useStore($paneHeightOverride(id))
|
||
|
||
useEffect(() => {
|
||
if (defaultCollapsed && $paneState(id).get() === undefined) {
|
||
setPaneHeightOverride(id, 0)
|
||
}
|
||
}, [defaultCollapsed, id])
|
||
|
||
const height = override ?? defaultHeight
|
||
const collapsed = height <= DETAIL_PANE_COLLAPSED_PX
|
||
// Sash drag mirrors the shell's y-axis pane resize: pointer capture on the
|
||
// top edge, clamped to [0, 70vh]; double-click resets to the default.
|
||
const [dragging, setDragging] = useState(false)
|
||
|
||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||
event.preventDefault()
|
||
const startY = event.clientY
|
||
const startHeight = height
|
||
const max = Math.round(window.innerHeight * DETAIL_PANE_MAX_VH)
|
||
setDragging(true)
|
||
|
||
const onMove = (move: globalThis.PointerEvent) => {
|
||
setPaneHeightOverride(id, Math.min(max, Math.max(0, Math.round(startHeight + (startY - move.clientY)))))
|
||
}
|
||
|
||
const onUp = () => {
|
||
window.removeEventListener('pointermove', onMove)
|
||
setDragging(false)
|
||
}
|
||
|
||
window.addEventListener('pointermove', onMove)
|
||
window.addEventListener('pointerup', onUp, { once: true })
|
||
}
|
||
|
||
return (
|
||
<section className="relative flex shrink-0 flex-col border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)">
|
||
<div
|
||
className="group/sash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
|
||
onDoubleClick={() => setPaneHeightOverride(id, undefined)}
|
||
onPointerDown={startDrag}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
|
||
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/sash:bg-(--ui-stroke-secondary)'
|
||
)}
|
||
/>
|
||
</div>
|
||
<header className="flex h-9 shrink-0 items-center gap-2 px-3">
|
||
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
|
||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||
{actions}
|
||
<Tip label={collapsed ? t.common.expand : t.common.collapse}>
|
||
<Button
|
||
aria-expanded={!collapsed}
|
||
aria-label={collapsed ? t.common.expand : t.common.collapse}
|
||
className={ICON_BUTTON}
|
||
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
|
||
</Button>
|
||
</Tip>
|
||
{onClose && (
|
||
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
|
||
<Codicon name="close" size="0.8125rem" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<div className="min-h-0 overflow-hidden" style={{ height: collapsed ? 0 : height }}>
|
||
{children}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="mb-1 flex h-6 shrink-0 items-center justify-between gap-2 pl-2 pr-1">
|
||
<div className="flex min-w-0 items-center gap-1.5">{left}</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">{right}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
aria-label={label}
|
||
className={cn(
|
||
ICON_BUTTON,
|
||
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground'
|
||
)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Codicon name="kebab-vertical" size="0.8125rem" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-44" sideOffset={6}>
|
||
{toggle && (
|
||
<DropdownMenuItem
|
||
disabled={toggle.disabled}
|
||
onSelect={event => {
|
||
// Keep the menu open so the switch is seen flipping.
|
||
event.preventDefault()
|
||
toggle.onToggle(!toggle.checked)
|
||
}}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">{toggle.label}</span>
|
||
<Switch
|
||
checked={toggle.checked}
|
||
className={cn('pointer-events-none shrink-0', !toggle.checked && 'opacity-60')}
|
||
size="xs"
|
||
tabIndex={-1}
|
||
/>
|
||
</DropdownMenuItem>
|
||
)}
|
||
{items.map(item => (
|
||
<DropdownMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
|
||
{item.label}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)
|
||
}
|
||
|
||
export function ListStripButton({
|
||
active,
|
||
children,
|
||
disabled,
|
||
onClick
|
||
}: {
|
||
active?: boolean
|
||
children: ReactNode
|
||
disabled?: boolean
|
||
onClick: () => void
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
'cursor-pointer text-[0.68rem] font-medium transition-colors disabled:opacity-40',
|
||
active ? 'text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
|
||
)}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
// content-visibility:auto lets the browser skip layout/paint for
|
||
// offscreen rows — the Capabilities lists routinely hold 80+ entries.
|
||
// Row height is already fixed (h-8/h-11), so skipped rows keep their
|
||
// exact size and scrollbar geometry never jumps.
|
||
'group/row row-hover flex w-full shrink-0 items-center rounded-md [content-visibility:auto] hover:text-foreground',
|
||
subtitle ? 'h-11' : 'h-8',
|
||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||
)}
|
||
id={rowId}
|
||
>
|
||
<RowButton
|
||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md pl-2 pr-1.5 text-left"
|
||
onClick={onSelect}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span
|
||
className={cn(
|
||
'block truncate text-[0.78rem]',
|
||
enabled ? 'font-medium text-foreground/85' : 'font-normal text-muted-foreground/60'
|
||
)}
|
||
>
|
||
{title}
|
||
</span>
|
||
{subtitle != null && (
|
||
<span className="flex min-w-0 items-center gap-1 text-[0.62rem] text-muted-foreground/50">
|
||
{typeof subtitle === 'string' ? <span className="truncate">{subtitle}</span> : subtitle}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{meta != null && (
|
||
<span className="shrink-0 rounded bg-(--ui-bg-quinary) px-1 py-px text-[0.6rem] tabular-nums leading-3.5 text-(--ui-text-tertiary)">
|
||
{meta}
|
||
</span>
|
||
)}
|
||
</RowButton>
|
||
{action != null ? (
|
||
<span className="mr-1.5 flex shrink-0 items-center">{action}</span>
|
||
) : (
|
||
<Switch
|
||
aria-label={toggleLabel ?? title}
|
||
checked={enabled}
|
||
className={cn('mr-1.5 shrink-0 cursor-pointer', !enabled && 'opacity-60')}
|
||
disabled={busy}
|
||
onCheckedChange={onToggle}
|
||
size="xs"
|
||
title={toggleLabel ?? title}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|