@hachej/boring-ui-kit 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pi-workspace",
4
+ "title": "Pi workspace layout",
5
+ "description": "ArtifactWorkspace: the chat with a right-hand panel that slides in at full height, a resizable divider, full screen, a full-screen sheet on a phone and an opt-in float-when-narrow (the host swaps in AmbientChat from pi-ambient). Layout only; what is open is the host's. Pair it with the viewers item for the panel contents. Also dock.tsx: composable docks (DockLayout, Dock, DockMain, useDock) for any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "react-dom@^19.0.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "@boring-ui/pi-chat",
12
+ "@boring-ui/utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "registry/pi-workspace/workspace.tsx",
17
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/** What the open panel can do to its workspace. Pass `onFullscreenChange` on to the viewer's top bar; it is absent in the phone sheet. */\nexport interface WorkspacePanelApi {\n readonly fullscreen: boolean;\n /** Absent on a phone, where the panel is already a full-screen sheet. */\n readonly onFullscreenChange: ((next: boolean) => void) | undefined;\n readonly close: () => void;\n readonly sheet: boolean;\n /** Float the chat over the panel (see `floatBelow`). Absent without `floatBelow`, while the chat already floats and in the phone sheet. pi-app's `AgentWorkspace` turns it into the viewers' \"Float chat\" action (`ViewerWindow.actions`). */\n readonly floatChat: (() => void) | undefined;\n}\n\n/** What `chat` can render as a function: whether the chat floats over the full-width panel, and how to put it back beside it. */\nexport interface WorkspaceChatLayout {\n readonly floating: boolean;\n readonly dock: () => void;\n}\n\nexport interface ArtifactWorkspaceProps {\n /**\n * Usually a `PiChat`. It stays mounted while the panel opens, resizes, goes full screen and closes. With `floatBelow`, pass a function that\n * returns the docked chat or, while `floating`, an `AmbientChat` on the same controller (`onDock` there is `dock`): the chat is then a different surface over one session.\n */\n readonly chat: ReactNode | ((layout: WorkspaceChatLayout) => ReactNode);\n /** Whether the panel is open. Closing is the host's decision (`onClose`); the workspace never unmounts `chat`. */\n readonly open: boolean;\n /** The panel content, usually a viewer whose top bar is the shared `ViewerFrame`. Receives what it needs for its close and full screen buttons. */\n readonly panel: ReactNode | ((api: WorkspacePanelApi) => ReactNode);\n readonly onClose: () => void;\n /** Full screen is controlled when both are given; otherwise the workspace keeps it. Escape leaves it. */\n readonly fullscreen?: boolean;\n readonly onFullscreenChange?: (next: boolean) => void;\n /** Width of the panel is remembered for this browser session under this key. */\n readonly storageKey?: string;\n /** Initial panel width in pixels, before the person drags the divider. */\n readonly defaultWidth?: number;\n readonly minPanel?: number;\n readonly minChat?: number;\n /** Below this container width the panel is a full-screen sheet and the divider is gone. */\n readonly sheetBelow?: number;\n /**\n * Opt in to a floating chat: when the divider is dragged until the chat is narrower than this many pixels, a hint appears and releasing floats the chat\n * (the panel takes the full width). Alt+Left on the divider and `api.floatChat` do the same without a pointer. Remembered for the session; ignored in the phone sheet.\n * Off by default.\n */\n readonly floatBelow?: number;\n /** The panel's accessible name. */\n readonly panelLabel?: string;\n /** The divider's accessible name and the hint shown while a drag would float the chat. */\n readonly labels?: { readonly resize?: string | undefined; readonly floatHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nconst STEP = 24, BIG_STEP = 96;\nconst read = (key: string): number | undefined => { try { const value = Number(sessionStorage.getItem(key)); return Number.isFinite(value) && value > 0 ? value : undefined; } catch { return undefined; } };\nconst write = (key: string, value: number) => { try { sessionStorage.setItem(key, String(Math.round(value))); } catch { /* the width is a convenience */ } };\nconst readFlag = (key: string): boolean => { try { return sessionStorage.getItem(key) === '1'; } catch { return false; } };\nconst writeFlag = (key: string, on: boolean) => { try { if (on) sessionStorage.setItem(key, '1'); else sessionStorage.removeItem(key); } catch { /* the layout is a convenience */ } };\n\n/**\n * Chat with an artifact panel beside it, as in Claude's chat apps. The panel slides in from the right at full height and the chat\n * narrows; a divider (pointer or arrow keys) resizes it, the width is remembered for the session; the panel can go full screen\n * (Escape leaves) and on a phone it is a full-screen sheet. With `floatBelow` the chat can float over a full-width panel instead of sitting beside it. The workspace owns layout only: what is open, and the viewer in it, are the host's.\n */\nexport function ArtifactWorkspace({ chat, open, panel, onClose, fullscreen: controlledFullscreen, onFullscreenChange, storageKey = 'boring.artifact-panel.width', defaultWidth = 640,\n minPanel = 360, minChat = 340, sheetBelow = 768, floatBelow, panelLabel = 'Artifact panel', labels, className }: ArtifactWorkspaceProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [stored, setStored] = useState<number>(() => read(storageKey) ?? defaultWidth);\n const [local, setLocal] = useState(false);\n const [entered, setEntered] = useState(false);\n const [dragging, setDragging] = useState(false);\n const [floatHint, setFloatHint] = useState(false);\n const [floatState, setFloatState] = useState(() => floatBelow !== undefined && readFlag(`${storageKey}.floating`));\n const controlled = controlledFullscreen !== undefined && onFullscreenChange !== undefined;\n const sheet = size > 0 && size < sheetBelow;\n const floating = open && !sheet && floatBelow !== undefined && floatState;\n const fullscreen = open && !sheet && (controlled ? controlledFullscreen : local);\n const setFullscreen = useCallback((next: boolean) => { if (controlled) onFullscreenChange!(next); else setLocal(next); }, [controlled, onFullscreenChange]);\n\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // The panel slides in on the frame after it mounts.\n const setFloating = useCallback((next: boolean) => { setFloatState(next); setFloatHint(false); writeFlag(`${storageKey}.floating`, next); }, [storageKey]);\n useEffect(() => {\n // With the panel closed there is nothing to float over: the chat docks back.\n if (!open) { setEntered(false); setLocal(false); setFloating(false); return; }\n const frame = requestAnimationFrame(() => setEntered(true));\n return () => cancelAnimationFrame(frame);\n }, [open, setFloating]);\n useEffect(() => {\n if (!fullscreen) return;\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) setFullscreen(false); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [fullscreen, setFullscreen]);\n\n const max = Math.max(minPanel, size - minChat);\n const width = Math.min(Math.max(stored, minPanel), max);\n const resize = (next: number) => { const clamped = Math.min(Math.max(next, minPanel), max); setStored(clamped); write(storageKey, clamped); };\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = root.current?.getBoundingClientRect();\n if (!box) return;\n const target = box.right - event.clientX;\n // Past the threshold the width stays at the last one above it; releasing there floats the chat.\n const below = floatBelow !== undefined && size - target < floatBelow;\n setFloatHint(below);\n if (!below) resize(target);\n };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture(event.pointerId); setDragging(false); if (floatHint) setFloating(true); setFloatHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n if (floatBelow !== undefined && event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); setFloating(true); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === 'ArrowLeft' ? width + step : event.key === 'ArrowRight' ? width - step : event.key === 'Home' ? max : event.key === 'End' ? minPanel : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const api: WorkspacePanelApi = { fullscreen, onFullscreenChange: sheet ? undefined : setFullscreen, close: onClose, sheet, floatChat: floatBelow !== undefined && !sheet && !floating ? () => setFloating(true) : undefined };\n const covering = open && (sheet || fullscreen);\n return <div ref={root} data-boring=\"artifact-workspace\" data-open={open ? 'true' : 'false'} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet && open ? 'true' : 'false'}\n className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>\n <div data-testid=\"workspace-chat\" data-floating={floating ? 'true' : undefined} {...(covering ? { inert: true, 'aria-hidden': true } : {})}\n className={cn('relative flex min-h-0 min-w-0 flex-col', floating ? 'w-0 flex-none' : 'flex-1', covering && 'invisible')}>\n {typeof chat === 'function' ? chat({ floating, dock: () => setFloating(false) }) : chat}\n {floatHint && <div role=\"status\" data-testid=\"workspace-float-hint\" className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4 backdrop-blur-[2px]\">\n <span className=\"rounded-full border border-border bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg\">{labels?.floatHint ?? 'Release to float the chat'}</span>\n </div>}\n </div>\n {open && <>\n {!sheet && !fullscreen && !floating && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize artifact panel'} aria-valuemin={minPanel} aria-valuemax={max} aria-valuenow={Math.round(width)}\n data-testid=\"workspace-divider\" data-dragging={dragging ? 'true' : undefined}\n onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setFloatHint(false); }}\n onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize touch-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring/70 group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>}\n <aside data-testid=\"workspace-panel\" aria-label={panelLabel} data-state={entered ? 'open' : 'entering'} data-floating={floating ? 'true' : undefined} data-fullscreen={fullscreen ? 'true' : 'false'} data-sheet={sheet ? 'true' : 'false'}\n style={sheet || fullscreen || floating ? undefined : { width, minWidth: minPanel }}\n className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none',\n sheet || fullscreen ? 'fixed inset-0 z-50' : floating ? 'flex-1' : 'shrink-0 border-l border-border',\n entered ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0', dragging && 'pointer-events-none select-none')}>\n {typeof panel === 'function' ? panel(api) : panel}\n </aside>\n </>}\n </div>;\n}\n",
18
+ "type": "registry:component",
19
+ "target": "components/pi-workspace/workspace.tsx"
20
+ },
21
+ {
22
+ "path": "registry/pi-workspace/dock.tsx",
23
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : String(Math.round(value))); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n return <main data-boring=\"dock-main\" className={cn('relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const floatingChosen = floatable && (record.floating ?? defaultFloating);\n const placement: DockPlacement = !open ? 'closed'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : floatingChosen ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setFloating = useCallback((next: boolean) => { if (floatable) update(id, { floating: next }); }, [floatable, id, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setOpen, toggle: () => setOpen(!open),\n float: () => { setFloating(true); if (!open) setOpen(true); },\n dock: () => { setFloating(false); if (!open) setOpen(true); },\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring/70 group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', docked && contained && '[contain:layout]',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4 backdrop-blur-[2px]\">\n <span className=\"rounded-full border border-border bg-popover px-4 py-2 text-center text-sm font-medium text-popover-foreground shadow-lg\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
24
+ "type": "registry:component",
25
+ "target": "components/pi-workspace/dock.tsx"
26
+ }
27
+ ],
28
+ "type": "registry:block"
29
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "provider-setup",
4
+ "title": "Provider setup",
5
+ "description": "A Tailwind popover for configuring model access: pick any registered Pi provider and model, save an API key (masked, shows \"saved\"), run a device-code subscription sign-in (code, link and status), set an optional gateway and see when a provider needs one. Transport-agnostic: props in, callbacks out, no fetch. Owned, editable source; fits the pi-chat controls slot.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "react-dom@^19.0.0",
9
+ "lucide-react@^1.51.0"
10
+ ],
11
+ "registryDependencies": [
12
+ "@boring-ui/theme",
13
+ "@boring-ui/utils"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/provider-setup/provider-setup.tsx",
18
+ "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent, ReactNode } from 'react';\nimport { CheckIcon, ChevronDownIcon, KeyRoundIcon, SettingsIcon } from 'lucide-react';\nimport { cn } from '../utils/utils';\n\n/** One model a provider offers. */\nexport interface ProviderModel { readonly id: string; readonly name?: string }\n\n/** One model provider the host has registered. The host decides the list; this component only displays it. */\nexport interface ProviderOption {\n readonly id: string;\n readonly name: string;\n readonly models: readonly ProviderModel[];\n /** How the person can authorize this provider: an API key, a subscription sign-in (device code), both or neither. */\n readonly auth: readonly ('api_key' | 'oauth')[];\n /** Credentials are already stored for this provider. */\n readonly configured: boolean;\n /** Browsers cannot call this provider directly; a gateway must be set. */\n readonly needsGateway?: boolean;\n /** Button text for the sign-in, such as \"Sign in with ChatGPT\". */\n readonly loginLabel?: string;\n}\n\n/** The current choice. */\nexport interface ProviderSetupValue { readonly provider: string; readonly modelId: string; readonly gateway?: string }\n\n/** What the person asked to save. `apiKey` is present only when they typed one. */\nexport interface ProviderSetupChange { readonly provider: string; readonly modelId: string; readonly gateway: string; readonly apiKey?: string }\n\n/** Progress of a device-code sign-in the host runs. */\nexport type ProviderLoginState =\n | { readonly state: 'idle' }\n | { readonly state: 'starting' }\n | { readonly state: 'pending'; readonly userCode: string; readonly verificationUri: string }\n | { readonly state: 'done' }\n | { readonly state: 'failed'; readonly message: string };\n\nexport interface ProviderSetupProps {\n readonly providers: readonly ProviderOption[];\n readonly value: ProviderSetupValue;\n /** Save the choice (and the key, when typed). Throw or reject with an Error to show its message. The component never fetches. */\n readonly onSave: (change: ProviderSetupChange) => void | Promise<void>;\n /** Start a subscription sign-in for one provider. Without it the sign-in button is hidden. */\n readonly onLogin?: (providerId: string) => void | Promise<void>;\n readonly loginState?: ProviderLoginState;\n /** Shown as the gateway field's placeholder. */\n readonly gatewayPlaceholder?: string;\n /** Hide the gateway field when the host has no gateway. */\n readonly showGateway?: boolean;\n /** Replaces the default trigger button. The popover anchors to it. */\n readonly trigger?: ReactNode;\n readonly label?: string;\n /** Where the panel opens relative to the trigger. */\n readonly side?: 'bottom' | 'top';\n readonly align?: 'start' | 'end';\n readonly defaultOpen?: boolean;\n readonly className?: string;\n}\n\nconst field = 'h-9 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 text-base outline-none sm:text-sm placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:h-11 pointer-coarse:h-11';\nconst button = 'inline-flex h-9 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-3.5 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 max-sm:h-11 pointer-coarse:h-11';\n\n/** Popover for choosing a provider and model and giving access to it. Transport-agnostic: props in, callbacks out. */\nexport function ProviderSetup({ providers, value, onSave, onLogin, loginState = { state: 'idle' }, gatewayPlaceholder = 'https://…/gateway', showGateway = true,\n trigger, label = 'Model', side = 'bottom', align = 'end', defaultOpen = false, className }: ProviderSetupProps) {\n const [open, setOpen] = useState(defaultOpen);\n const root = useRef<HTMLDivElement>(null);\n const id = useId();\n const current = providers.find(item => item.id === value.provider);\n const [provider, setProvider] = useState(value.provider);\n const [modelId, setModelId] = useState(value.modelId);\n const [gateway, setGateway] = useState(value.gateway ?? '');\n const [apiKey, setApiKey] = useState('');\n const [saving, setSaving] = useState(false);\n const [saved, setSaved] = useState(false);\n const [error, setError] = useState<string | undefined>();\n const chosen = providers.find(item => item.id === provider) ?? providers[0];\n\n // A new value from the host (after a save or a sign-in) replaces the form's draft.\n useEffect(() => { setProvider(value.provider); setModelId(value.modelId); setGateway(value.gateway ?? ''); }, [value.provider, value.modelId, value.gateway]);\n useEffect(() => {\n if (!open) return;\n const outside = (event: PointerEvent) => { if (root.current && !root.current.contains(event.target as Node)) setOpen(false); };\n const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); };\n document.addEventListener('pointerdown', outside); document.addEventListener('keydown', escape);\n return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); };\n }, [open]);\n\n if (!chosen) return null;\n const models = chosen.models.some(model => model.id === modelId) || !modelId ? chosen.models : [{ id: modelId }, ...chosen.models];\n const canKey = chosen.auth.includes('api_key'), canLogin = chosen.auth.includes('oauth') && onLogin !== undefined;\n const gatewayMissing = chosen.needsGateway === true && !gateway.trim();\n\n async function save(event: FormEvent) {\n event.preventDefault();\n setSaving(true); setError(undefined); setSaved(false);\n try {\n await onSave({ provider: chosen!.id, modelId, gateway: gateway.trim(), ...(apiKey ? { apiKey } : {}) });\n setApiKey(''); setSaved(true);\n } catch (cause) { setError(cause instanceof Error ? cause.message : 'Could not save.'); }\n finally { setSaving(false); }\n }\n\n return <div ref={root} data-boring=\"provider-setup\" className={cn('provider-setup relative inline-block text-foreground', className)}>\n <button type=\"button\" data-testid=\"provider-setup-trigger\" aria-haspopup=\"dialog\" aria-expanded={open} aria-controls={`${id}-panel`} onClick={() => setOpen(!open)}\n className={trigger ? 'contents' : cn(button, 'border border-border bg-background hover:bg-muted max-w-56 truncate')}>\n {trigger ?? <><SettingsIcon className=\"size-3.5\" aria-hidden=\"true\" /><span className=\"truncate\">{current ? `${current.name} · ${value.modelId}` : label}</span><ChevronDownIcon className=\"size-3.5\" aria-hidden=\"true\" /></>}\n </button>\n {open && <form id={`${id}-panel`} role=\"dialog\" aria-label=\"Model access\" data-testid=\"provider-setup-panel\" onSubmit={save}\n className={cn('absolute z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-3 rounded-xl border border-border bg-card p-4 text-card-foreground shadow-lg',\n side === 'bottom' ? 'top-full mt-2' : 'bottom-full mb-2', align === 'end' ? 'right-0' : 'left-0')}>\n <label className=\"flex flex-col gap-1 text-xs font-medium text-muted-foreground\">Provider\n <select data-testid=\"provider-select\" value={chosen.id} className={field}\n onChange={event => { const next = providers.find(item => item.id === event.target.value); if (!next) return; setProvider(next.id); setModelId(next.models[0]?.id ?? ''); setApiKey(''); setSaved(false); }}>\n {providers.map(item => <option key={item.id} value={item.id}>{item.name}{item.configured ? ' ✓' : ''}</option>)}\n </select></label>\n <label className=\"flex flex-col gap-1 text-xs font-medium text-muted-foreground\">Model\n <select data-testid=\"model-select\" value={modelId} className={field} onChange={event => { setModelId(event.target.value); setSaved(false); }}>\n {models.map(model => <option key={model.id} value={model.id}>{model.name ?? model.id}</option>)}\n </select></label>\n\n {canKey && <label className=\"flex flex-col gap-1 text-xs font-medium text-muted-foreground\">\n <span className=\"flex items-center justify-between\">API key{chosen.configured && <span data-testid=\"key-saved\" className=\"inline-flex items-center gap-1 font-normal text-emerald-600 dark:text-emerald-400\"><CheckIcon className=\"size-3\" aria-hidden=\"true\" />saved</span>}</span>\n <span className=\"relative flex items-center\"><KeyRoundIcon className=\"pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground\" aria-hidden=\"true\" />\n <input data-testid=\"api-key-input\" type=\"password\" autoComplete=\"off\" spellCheck={false} value={apiKey} className={cn(field, 'pl-8')}\n placeholder={chosen.configured ? '•••••••• (type to replace)' : 'Paste your key'} onChange={event => { setApiKey(event.target.value); setSaved(false); }} /></span>\n </label>}\n\n {canLogin && <div className=\"flex flex-col gap-2\">\n <button type=\"button\" data-testid=\"provider-login\" disabled={loginState.state === 'starting' || loginState.state === 'pending'} onClick={() => { void onLogin?.(chosen.id); }}\n className={cn(button, 'border border-border bg-background hover:bg-muted')}>{chosen.loginLabel ?? `Sign in with ${chosen.name}`}{chosen.auth.includes('oauth') && chosen.configured && loginState.state === 'idle' ? ' again' : ''}</button>\n {loginState.state === 'starting' && <p role=\"status\" className=\"m-0 text-xs text-muted-foreground\">Requesting a sign-in code…</p>}\n {loginState.state === 'pending' && <p role=\"status\" data-testid=\"login-pending\" className=\"m-0 text-sm\">Open <a href={loginState.verificationUri} target=\"_blank\" rel=\"noopener noreferrer\" className=\"underline underline-offset-2 [overflow-wrap:anywhere]\">{loginState.verificationUri}</a> and enter\n <strong data-testid=\"device-code\" className=\"ms-1 rounded-md bg-muted px-1.5 py-0.5 font-mono tracking-wider\">{loginState.userCode}</strong>\n <span className=\"mt-1 block text-xs text-muted-foreground\">Waiting for you to approve…</span></p>}\n {loginState.state === 'done' && <p role=\"status\" data-testid=\"login-done\" className=\"m-0 inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400\"><CheckIcon className=\"size-3\" aria-hidden=\"true\" />Signed in</p>}\n {loginState.state === 'failed' && <p role=\"alert\" data-testid=\"login-failed\" className=\"m-0 text-xs text-destructive\">{loginState.message}</p>}\n </div>}\n\n {showGateway && <label className=\"flex flex-col gap-1 text-xs font-medium text-muted-foreground\">Gateway (optional)\n <input data-testid=\"gateway-input\" value={gateway} placeholder={gatewayPlaceholder} className={field} onChange={event => { setGateway(event.target.value); setSaved(false); }} /></label>}\n {chosen.needsGateway && <p data-testid=\"gateway-note\" className={cn('m-0 text-xs', gatewayMissing ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground')}>\n {chosen.name} refuses model calls made directly from a web page. Set a gateway: a pass-through that adds no secrets.</p>}\n\n <div className=\"flex items-center gap-3\">\n <button type=\"submit\" data-testid=\"provider-save\" disabled={saving} className={cn(button, 'bg-primary text-primary-foreground hover:bg-primary/90')}>{saving ? 'Saving…' : 'Save'}</button>\n {saved && <span role=\"status\" data-testid=\"provider-saved\" className=\"inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400\"><CheckIcon className=\"size-3\" aria-hidden=\"true\" />Saved</span>}\n </div>\n {error && <p role=\"alert\" data-testid=\"provider-error\" className=\"m-0 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive\">{error}</p>}\n </form>}\n </div>;\n}\n",
19
+ "type": "registry:component",
20
+ "target": "components/provider-setup/provider-setup.tsx"
21
+ }
22
+ ],
23
+ "type": "registry:component"
24
+ }