@hachej/boring-ui-kit 0.4.58 → 0.4.60
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.
- package/bin/boring-ui-blocks.mjs +49 -4
- package/package.json +6 -6
- package/r/_shared.json +19 -0
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +17 -17
- package/r/board-viewer.json +10 -10
- package/r/button.json +4 -4
- package/r/data-table.json +21 -0
- package/r/diagram-viewer.json +6 -4
- package/r/dock.json +1 -1
- package/r/documents.json +13 -14
- package/r/drawing-viewer.json +6 -4
- package/r/empty-state.json +22 -0
- package/r/feedback.json +14 -14
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/inline-question.json +22 -0
- package/r/kpi-stat.json +22 -0
- package/r/list-row.json +21 -0
- package/r/looks-viewer.json +6 -6
- package/r/maquette-viewer.json +7 -6
- package/r/markdown-editor.json +10 -9
- package/r/mockup-viewer.json +5 -5
- package/r/page-header.json +22 -0
- package/r/pi-ambient.json +12 -12
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -17
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +4 -4
- package/r/pi-chat-attachments.json +4 -4
- package/r/pi-chat-markdown.json +3 -3
- package/r/pi-chat-mentions.json +4 -4
- package/r/pi-chat-pickers.json +4 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +3 -3
- package/r/pi-chat-slash.json +5 -5
- package/r/pi-chat-subagent.json +4 -4
- package/r/pi-chat-voice.json +7 -7
- package/r/pi-chat.json +18 -18
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +7 -7
- package/r/provider-setup.json +4 -4
- package/r/record-viewer.json +5 -3
- package/r/registry.json +577 -280
- package/r/shared-chat.json +11 -11
- package/r/space-card.json +22 -0
- package/r/spaces.json +7 -5
- package/r/status-narrative.json +22 -0
- package/r/task-list-viewer.json +28 -3
- package/r/theme.json +118 -50
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +5 -5
- package/r/viewer-frame.json +9 -9
- package/r/viewer-html.json +7 -7
- package/r/viewer-image.json +5 -5
- package/r/viewer-markdown.json +7 -7
- package/r/viewer-pdf.json +6 -6
- package/r/viewers.json +6 -6
- package/r/windows.json +4 -4
- package/r/wiring-chips.json +22 -0
package/r/pi-workspace.json
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"react-dom@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-chat.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/dock.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/placed-agent.json
CHANGED
|
@@ -5,19 +5,19 @@
|
|
|
5
5
|
"description": "PlacedAgentWorkspace: AgentWorkspace over ONE controller, as a page (the app's Agent tab) or collapsed beside the app. Collapsed on a wide screen it is a floating AmbientChat; on a phone inside an app shell the same controller is lent to the shell's bottom drawer (useAgentLoan of app-shell), so one chat drawer serves the app. Outside a shell on a phone it stays the page. Placement is remembered (usePlacedAgentState) or controlled; usePlacedAgent gives collapse and expand to buttons inside it. The page stays mounted while collapsed, so its open file survives. Layout only: Pi owns the conversation.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
|
-
"react-dom@^19.0.0"
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
8
|
+
"react-dom@^19.0.0"
|
|
10
9
|
],
|
|
11
10
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-app.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/pi-ambient.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/app-shell.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
|
19
19
|
"path": "registry/placed-agent/placed-agent-workspace.tsx",
|
|
20
|
-
"content": "'use client';\n\n// PlacedAgentWorkspace: the demo's arrangement over ONE controller. The agent is a page (`AgentWorkspace`, the app's « Agent » tab) or\n// collapsed beside the app: on a wide screen a floating `AmbientChat`, on a phone inside an app shell the shell's own bottom drawer (the\n// controller is lent to the shell's agent layer, `useAgentLoan`). Pi owns the conversation (the controller holds the draft and the rows);\n// this is host layout only, so no second chat state exists. The page stays mounted (hidden) while collapsed, so its open file survives.\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Maximize2Icon, PictureInPicture2Icon } from '
|
|
20
|
+
"content": "'use client';\n\n// PlacedAgentWorkspace: the demo's arrangement over ONE controller. The agent is a page (`AgentWorkspace`, the app's « Agent » tab) or\n// collapsed beside the app: on a wide screen a floating `AmbientChat`, on a phone inside an app shell the shell's own bottom drawer (the\n// controller is lent to the shell's agent layer, `useAgentLoan`). Pi owns the conversation (the controller holds the draft and the rows);\n// this is host layout only, so no second chat state exists. The page stays mounted (hidden) while collapsed, so its open file survives.\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { Maximize2Icon, PictureInPicture2Icon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { AgentWorkspace } from '../pi-app/agent-workspace';\nimport type { AgentWorkspaceProps, OpenedView } from '../pi-app/agent-workspace';\nimport { AmbientChat, usePhone } from '../pi-ambient/ambient';\nimport { useAgentLoan } from '../app-shell/agent-layer';\nimport type { AgentSpec } from '../app-shell/agent-layer';\nimport { cn } from '../utils/utils';\n\nexport type AgentPlacement = 'tab' | 'ambient' | 'tab+ambient';\n\nexport type PlacedAgentWorkspaceProps = AgentWorkspaceProps & {\n /** `tab` is a plain `AgentWorkspace`; `ambient` and `tab+ambient` (default) add the collapse and expand actions. */\n readonly placement?: AgentPlacement;\n /** Controlled: the agent is collapsed. Otherwise it is remembered under `${storageKey}.placement` (see `usePlacedAgentState`). */\n readonly collapsed?: boolean;\n /** Called when the person collapses or expands; the app usually moves to another screen or back to the Agent tab here. */\n readonly onCollapsedChange?: (collapsed: boolean) => void;\n};\n\nexport interface PlacedAgent {\n readonly mode: 'tab' | 'ambient';\n /** Whether collapsing is possible here (not on a phone outside an app shell, nor with `placement=\"tab\"`). */\n readonly collapsible: boolean;\n readonly collapse: () => void;\n readonly expand: () => void;\n}\nconst PlacedContext = createContext<PlacedAgent | null>(null);\n\n/** The collapse and expand actions and the mode, for the page's `controls` and any button rendered inside the workspace. */\nexport function usePlacedAgent(): PlacedAgent {\n const placed = useContext(PlacedContext);\n if (!placed) throw new Error('usePlacedAgent needs a PlacedAgentWorkspace');\n return placed;\n}\n\nconst words = {\n en: { collapse: 'Collapse the agent', expand: 'Open the agent as a page' },\n fr: { collapse: 'Réduire l’agent', expand: 'Ouvrir l’agent en page' },\n} as const;\n\nconst read = (key: string): boolean => { try { return globalThis.localStorage?.getItem(key) === 'ambient'; } catch { return false; } };\n/** The remembered « collapsed » flag, for an app that needs to set it from its own navigation (pass both to `PlacedAgentWorkspace`). */\nexport function usePlacedAgentState(storageKey: string): readonly [boolean, (collapsed: boolean) => void] {\n const key = `${storageKey}.placement`;\n const [collapsed, set] = useState(() => read(key));\n const change = useCallback((next: boolean) => { set(next); try { globalThis.localStorage?.setItem(key, next ? 'ambient' : 'tab'); } catch { /* a convenience */ } }, [key]);\n return [collapsed, change];\n}\n\nexport function PlacedAgentWorkspace(props: PlacedAgentWorkspaceProps) {\n const { placement = 'tab+ambient', collapsed: controlled, onCollapsedChange, storageKey = 'boring.agent-workspace', controller, chat, locale, controls, opened: openedProp, defaultOpened = null, onOpenedChange, ...rest } = props;\n const [remembered, remember] = usePlacedAgentState(storageKey);\n const asked = placement !== 'tab' && (controlled ?? remembered);\n // Where the collapsed agent can go: the shell's drawer on a phone, a floating chat otherwise; a phone without a shell stays a page.\n const phone = usePhone();\n const loan = useAgentLoan();\n const drawer = phone && Boolean(loan?.compact && loan.available);\n const collapsible = placement !== 'tab' && (!phone || drawer);\n const mode: 'tab' | 'ambient' = collapsible && asked ? 'ambient' : 'tab';\n\n const [ownOpened, setOwnOpened] = useState<OpenedView | null>(defaultOpened);\n const opened = openedProp !== undefined ? openedProp : ownOpened;\n const setOpened = useCallback((next: OpenedView | null) => { setOwnOpened(next); onOpenedChange?.(next); }, [onOpenedChange]);\n\n const collapse = useCallback(() => { if (controlled === undefined) remember(true); onCollapsedChange?.(true); }, [controlled, remember, onCollapsedChange]);\n const expand = useCallback(() => { if (controlled === undefined) remember(false); onCollapsedChange?.(false); }, [controlled, remember, onCollapsedChange]);\n const api = useMemo<PlacedAgent>(() => ({ mode, collapsible, collapse, expand }), [mode, collapsible, collapse, expand]);\n\n const text = words[(locale ?? chat?.locale) === 'fr' ? 'fr' : 'en'];\n // A file the floating chat or the drawer's chat names opens in the page, which comes back.\n const openFile = useCallback((path: string) => { setOpened({ kind: 'file', path }); expand(); }, [setOpened, expand]);\n const chatProps = useMemo(() => ({ ...chat, onOpenFile: openFile }), [chat, openFile]);\n\n // The phone hand-off: the same controller becomes the shell's agent while collapsed. Lending is one effect (so a new spec never closes the\n // drawer) and taking it back another (so only leaving the collapsed mode does).\n const spec = useMemo<AgentSpec | undefined>(() => controller ? { controller, chatProps: { ...chatProps, ...(locale ? { locale } : {}) },\n headerActions: [{ id: 'expand', label: text.expand, icon: Maximize2Icon, onSelect: expand }] } : undefined, [controller, chatProps, locale, text.expand, expand]);\n const lent = mode === 'ambient' && drawer && Boolean(spec);\n const lend = loan?.lend;\n // Collapsing while mounted (the button, or the app's own navigation) opens it at peek; a placement restored on load is only lent.\n const wasLent = useRef(false), mounted = useRef(false);\n useEffect(() => { if (lent && lend) lend(spec, mounted.current && !wasLent.current ? 'peek' : undefined); wasLent.current = lent; }, [lent, lend, spec]);\n useEffect(() => { mounted.current = true; }, []);\n useEffect(() => { if (!lent || !lend) return; return () => lend(undefined); }, [lent, lend]);\n\n const collapseButton = collapsible && <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"placed-collapse\" aria-label={text.collapse} title={text.collapse} onClick={collapse}><PictureInPicture2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const barControls = (state: { readonly panelOpen: boolean }): ReactNode => <>{typeof controls === 'function' ? controls(state) : controls}{collapseButton}</>;\n\n const floating = mode === 'ambient' && !drawer && controller;\n return <PlacedContext.Provider value={api}>\n <div data-testid=\"placed-agent\" data-mode={mode} hidden={mode === 'ambient'} inert={mode === 'ambient'} className={cn('h-full min-h-0 min-w-0 flex-1', mode === 'ambient' ? 'hidden' : 'flex')}>\n <AgentWorkspace {...rest} {...(controller ? { controller } : {})} {...(chat ? { chat } : {})} {...(locale ? { locale } : {})} storageKey={storageKey} opened={opened} onOpenedChange={setOpened} controls={barControls} />\n </div>\n {floating && createPortal(<AmbientChat key={rest.conversationId} {...chatProps} controller={controller} variant=\"surface\" defaultState=\"expanded\" storageKey={`${storageKey}.ambient`} storage=\"local\" onDock={expand} />, document.body)}\n </PlacedContext.Provider>;\n}\n",
|
|
21
21
|
"type": "registry:component",
|
|
22
22
|
"target": "components/placed-agent/placed-agent-workspace.tsx"
|
|
23
23
|
}
|
package/r/provider-setup.json
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
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
|
-
"react-dom@^19.0.0"
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
8
|
+
"react-dom@^19.0.0"
|
|
10
9
|
],
|
|
11
10
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/utils.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "registry/provider-setup/provider-setup.tsx",
|
|
17
|
-
"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/** Only http(s) addresses become links; any other scheme is shown as text. */\nfunction isWebLink(uri: string): boolean {\n try { return /^https?:$/.test(new URL(uri).protocol); } catch { return false; }\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the host's new value resets the draft once per change, after commit\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 {isWebLink(loginState.verificationUri) ? <a href={loginState.verificationUri} target=\"_blank\" rel=\"noopener noreferrer\" className=\"underline underline-offset-2 [overflow-wrap:anywhere]\">{loginState.verificationUri}</a> : <span data-testid=\"login-uri\" className=\"[overflow-wrap:anywhere]\">{loginState.verificationUri}</span>} 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",
|
|
17
|
+
"content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { FormEvent, ReactNode } from 'react';\nimport { CheckIcon, ChevronDownIcon, SettingsIcon } from '../_shared/icons';\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/** Only http(s) addresses become links; any other scheme is shown as text. */\nfunction isWebLink(uri: string): boolean {\n try { return /^https?:$/.test(new URL(uri).protocol); } catch { return false; }\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the host's new value resets the draft once per change, after commit\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 {isWebLink(loginState.verificationUri) ? <a href={loginState.verificationUri} target=\"_blank\" rel=\"noopener noreferrer\" className=\"underline underline-offset-2 [overflow-wrap:anywhere]\">{loginState.verificationUri}</a> : <span data-testid=\"login-uri\" className=\"[overflow-wrap:anywhere]\">{loginState.verificationUri}</span>} 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",
|
|
18
18
|
"type": "registry:component",
|
|
19
19
|
"target": "components/provider-setup/provider-setup.tsx"
|
|
20
20
|
}
|
package/r/record-viewer.json
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
"title": "Record viewer",
|
|
5
5
|
"description": "Typed RecordTable and RecordList, with grouping, app-defined status badges and host row actions. createRecordsKind renders *.records.json (records@1), with a phone list and exact raw-text fallback for invalid files. English and French labels and locale formatting.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"react@^19.0.0"
|
|
8
|
-
|
|
7
|
+
"react@^19.0.0"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.60/r/_shared.json"
|
|
9
11
|
],
|
|
10
12
|
"files": [
|
|
11
13
|
{
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
},
|
|
29
31
|
{
|
|
30
32
|
"path": "registry/record-viewer/records-kind.tsx",
|
|
31
|
-
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { Rows3Icon } from '
|
|
33
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { Rows3Icon } from '../_shared/icons';\nimport { isRecordsPath } from './records';\nimport { recordsLabelsFor } from './records-labels';\nimport type { RecordTableProps, Words } from './record-viewer';\n\nconst RecordsViewer = lazy(() => import('./record-viewer').then(module => ({ default: module.RecordsViewer })));\n\nexport interface RecordsKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n/** The `*.records.json` kind; the viewer loads on first open. */\nexport function createRecordsKind(options: Words & { readonly onAction?: RecordTableProps['onAction'] } = {}): RecordsKind {\n return { match: target => isRecordsPath(target.resource.path), label: recordsLabelsFor(options.locale, options.labels).kind, icon: Rows3Icon,\n renderer: ({ text }) => <RecordsViewer text={text} {...options} /> };\n}\nexport const recordsKind: RecordsKind = createRecordsKind();\n",
|
|
32
34
|
"type": "registry:component",
|
|
33
35
|
"target": "components/record-viewer/records-kind.tsx"
|
|
34
36
|
}
|