@hachej/boring-ui-kit 0.3.1 → 0.4.0

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.
Files changed (55) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/dist/canvas-editor-native.d.ts +4 -1
  4. package/dist/canvas-editor-native.js +8 -6
  5. package/dist/canvas-editor.js +1 -1
  6. package/dist/canvas-proposals.d.ts +3 -1
  7. package/dist/canvas-proposals.js +4 -3
  8. package/dist/experience-document-viewer.d.ts +3 -0
  9. package/dist/experience-document-viewer.js +6 -5
  10. package/dist/experience.d.ts +3 -0
  11. package/dist/experience.js +2 -1
  12. package/dist/file-tree-view.d.ts +76 -3
  13. package/dist/file-tree-view.js +172 -39
  14. package/dist/file-tree.d.ts +3 -0
  15. package/dist/file-tree.js +17 -2
  16. package/dist/html-viewer.d.ts +4 -0
  17. package/dist/html-viewer.js +5 -3
  18. package/dist/markdown-autosave.d.ts +13 -0
  19. package/dist/markdown-autosave.js +94 -0
  20. package/dist/markdown-editor.d.ts +16 -0
  21. package/dist/markdown-editor.js +81 -17
  22. package/dist/markdown-extensions.js +9 -1
  23. package/dist/markdown-proposal-diff.d.ts +2 -1
  24. package/dist/markdown-proposal-diff.js +3 -2
  25. package/dist/markdown-safety.js +3 -1
  26. package/dist/native-chat.js +1 -1
  27. package/dist/remote-chat.js +5 -1
  28. package/dist/text-buffer.js +3 -2
  29. package/dist/text-draft-controls.d.ts +3 -1
  30. package/dist/text-draft-controls.js +3 -2
  31. package/dist/viewer-components.d.ts +14 -0
  32. package/dist/viewer-components.js +1 -0
  33. package/package.json +4 -4
  34. package/r/app-shell.json +46 -21
  35. package/r/button.json +19 -11
  36. package/r/dock.json +2 -2
  37. package/r/documents.json +59 -0
  38. package/r/feedback-preview.json +8 -2
  39. package/r/feedback.json +46 -325
  40. package/r/file-tree.json +14 -6
  41. package/r/html-viewer.json +8 -42
  42. package/r/markdown-editor.json +38 -87
  43. package/r/pi-ambient.json +15 -8
  44. package/r/pi-app.json +30 -19
  45. package/r/pi-chat.json +89 -70
  46. package/r/pi-workspace.json +7 -5
  47. package/r/provider-setup.json +9 -4
  48. package/r/registry.json +505 -878
  49. package/r/task-list-viewer.json +11 -21
  50. package/r/theme.json +23 -4
  51. package/r/version-history.json +24 -0
  52. package/r/viewers.json +139 -320
  53. package/styles/file-tree.css +41 -42
  54. package/dist/file-tree-shadcn.d.ts +0 -8
  55. package/dist/file-tree-shadcn.js +0 -14
@@ -8,15 +8,17 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/dock.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/dock.json",
15
+ "badge",
16
+ "card"
15
17
  ],
16
18
  "files": [
17
19
  {
18
20
  "path": "registry/pi-workspace/workspace.tsx",
19
- "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] 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",
21
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\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: () => unknown;\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 /** Return false when a synchronous host guard refuses to close; Back then restores the sheet entry. */\n readonly onClose: () => unknown;\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 /**\n * `split` (default): the panel is a full-height column with a hard border and a visible divider. `inset`: the panel is a rounded card inside the\n * workspace (a margin around it, a border and a soft shadow on the card only) and the divider remains visible and strengthens on hover, focus or drag, so the panel\n * reads as part of the chat's own surface, as in Claude's artifact panel. Resizing works the same.\n */\n readonly variant?: 'split' | 'inset';\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 opens on 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,\n minPanel = 360, minChat = 360, sheetBelow = 641, floatBelow, panelLabel = 'Artifact panel', variant = 'split', labels, className }: ArtifactWorkspaceProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [stored, setStored] = useState<number | undefined>(() => read(storageKey) ?? defaultWidth);\n const closeRef = useRef(onClose); closeRef.current = onClose;\n const fullscreenRef = useRef(false);\n const opener = useRef<HTMLElement | null>(null);\n const swipe = useRef<{ x: number; y: number } | null>(null);\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 fullscreenRef.current = fullscreen;\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 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 useEffect(() => {\n if (!open) return;\n opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n const escape = (event: globalThis.KeyboardEvent) => {\n if (event.key !== 'Escape' || event.defaultPrevented || fullscreenRef.current) return;\n event.preventDefault(); closeRef.current();\n };\n document.addEventListener('keydown', escape);\n return () => {\n document.removeEventListener('keydown', escape);\n const previous = opener.current;\n const target = previous?.isConnected && previous !== document.body ? previous : root.current?.querySelector<HTMLElement>('[data-testid=composer-input]');\n target?.focus({ preventScroll: true });\n // A shadcn Button transitions its inherited visibility, so it stays hidden (unfocusable) briefly after the sheet closes: retry per frame.\n if (target) { let frames = 30; const retry = () => { if (document.activeElement === target || document.activeElement !== document.body || frames-- <= 0) return; target.focus({ preventScroll: true }); requestAnimationFrame(retry); }; requestAnimationFrame(retry); }\n };\n }, [open]);\n useEffect(() => {\n if (!open || !sheet) return;\n const key = `artifact-${Math.random()}`;\n const history = window.history;\n const enter = () => history.pushState({ ...history.state, boringArtifact: key }, '');\n enter();\n const back = () => {\n if (history.state?.boringArtifact === key) return;\n void Promise.resolve(closeRef.current()).then(closed => { if (closed === false) enter(); });\n };\n window.addEventListener('popstate', back);\n return () => { window.removeEventListener('popstate', back); if (history.state?.boringArtifact === key) history.back(); };\n }, [open, sheet]); // The mounted sheet keeps its own Back entry, independent of renders.\n\n const max = Math.max(minPanel, size - minChat);\n const width = Math.min(Math.max(stored ?? Math.max(minPanel, size - 420), 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 // The inset panel is drawn by the host's Card behind it (a sibling layer, so a placement change never remounts the panel);\n // every other placement is a plain region of the page.\n const inset = variant === 'inset' && !sheet && !fullscreen && !floating;\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\">\n <Badge variant=\"secondary\">{labels?.floatHint ?? 'Release to float the chat'}</Badge>\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.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setFloatHint(false); }}\n onKeyDown={keys} onDoubleClick={() => resize(defaultWidth ?? size - 420)}\n className=\"group relative z-10 -mx-1.5 w-3 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/65', 'transition-colors group-hover:w-0.5 group-hover:bg-ring 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 onTouchStart={event => { const point = event.touches[0]; swipe.current = sheet && point && point.clientX < 32 ? { x: point.clientX, y: point.clientY } : null; }} onTouchEnd={event => { const point = event.changedTouches[0], start = swipe.current; swipe.current = null; if (point && start && point.clientX - start.x > 80 && Math.abs(point.clientY - start.y) < 60) onClose(); }} 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 transition-opacity motion-reduce:transition-none', !inset && 'bg-background text-foreground',\n sheet || fullscreen ? 'fixed inset-0 z-50' : floating ? 'flex-1' : inset ? 'relative isolate my-3 me-3 ms-0 shrink-0' : 'shrink-0',\n entered ? 'opacity-100' : 'opacity-0', dragging && 'pointer-events-none select-none')}>\n {inset && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {typeof panel === 'function' ? panel(api) : panel}\n </aside>\n </>}\n </div>;\n}\n",
20
22
  "type": "registry:component",
21
23
  "target": "components/pi-workspace/workspace.tsx"
22
24
  },
@@ -2,20 +2,25 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "provider-setup",
4
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.",
5
+ "description": "A shadcn 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
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
8
  "react-dom@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/theme.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.0/r/utils.json",
14
+ "button",
15
+ "input",
16
+ "label",
17
+ "native-select",
18
+ "popover"
14
19
  ],
15
20
  "files": [
16
21
  {
17
22
  "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",
23
+ "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { FormEvent, ReactNode } from 'react';\nimport { CheckIcon, ChevronDownIcon, SettingsIcon } from 'lucide-react';\nimport { cn } from '../utils/utils';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\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\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 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\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 <Popover defaultOpen={defaultOpen}>\n <PopoverTrigger render={<Button variant=\"ghost\" data-testid=\"provider-setup-trigger\" data-boring=\"provider-setup\" className={cn(trigger ? 'contents' : 'max-w-56', className)} />}>\n {trigger ?? <><SettingsIcon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"truncate\">{current ? `${current.name} · ${value.modelId}` : label}</span><ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" /></>}\n </PopoverTrigger>\n <PopoverContent side={side} align={align} aria-label=\"Model access\" data-testid=\"provider-setup-panel\" className=\"pi-chat w-[min(24rem,calc(100vw-2rem))]\">\n <form onSubmit={save} className=\"flex flex-col gap-3\">\n <Label className=\"flex-col items-stretch\">Provider\n <NativeSelect data-testid=\"provider-select\" value={chosen.id} className=\"w-full\"\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 => <NativeSelectOption key={item.id} value={item.id}>{item.name}{item.configured ? ' ✓' : ''}</NativeSelectOption>)}\n </NativeSelect></Label>\n <Label className=\"flex-col items-stretch\">Model\n <NativeSelect data-testid=\"model-select\" value={modelId} className=\"w-full\" onChange={event => { setModelId(event.target.value); setSaved(false); }}>\n {models.map(model => <NativeSelectOption key={model.id} value={model.id}>{model.name ?? model.id}</NativeSelectOption>)}\n </NativeSelect></Label>\n\n {canKey && <Label className=\"flex-col items-stretch\">\n <span className=\"flex items-center justify-between\">API key{chosen.configured && <span data-testid=\"key-saved\" className=\"inline-flex items-center gap-1 text-muted-foreground\"><CheckIcon className=\"size-3\" aria-hidden=\"true\" />saved</span>}</span>\n <Input data-testid=\"api-key-input\" type=\"password\" autoComplete=\"off\" spellCheck={false} value={apiKey}\n placeholder={chosen.configured ? '•••••••• (type to replace)' : 'Paste your key'} onChange={event => { setApiKey(event.target.value); setSaved(false); }} />\n </Label>}\n\n {canLogin && <div className=\"flex flex-col gap-2\">\n <Button variant=\"outline\" data-testid=\"provider-login\" disabled={loginState.state === 'starting' || loginState.state === 'pending'} onClick={() => { void onLogin?.(chosen.id); }}>{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 <code data-testid=\"device-code\" className=\"ms-1 bg-muted px-1.5 py-0.5 font-mono tracking-wider\">{loginState.userCode}</code>\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-muted-foreground\"><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-col items-stretch\">Gateway (optional)\n <Input data-testid=\"gateway-input\" value={gateway} placeholder={gatewayPlaceholder} 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-foreground' : '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}>{saving ? 'Saving…' : 'Save'}</Button>\n {saved && <span role=\"status\" data-testid=\"provider-saved\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><CheckIcon className=\"size-3\" aria-hidden=\"true\" />Saved</span>}\n </div>\n {error && <p role=\"alert\" data-testid=\"provider-error\" className=\"m-0 text-xs text-destructive\">{error}</p>}\n </form>\n </PopoverContent>\n </Popover>;\n}\n",
19
24
  "type": "registry:component",
20
25
  "target": "components/provider-setup/provider-setup.tsx"
21
26
  }