@hachej/boring-ui-kit 0.2.3 → 0.2.4
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/dist/remote-chat.d.ts +5 -0
- package/dist/remote-chat.js +2 -1
- package/package.json +2 -2
- package/r/app-shell.json +116 -0
- package/r/button.json +2 -2
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +3 -3
- package/r/html-viewer.json +2 -2
- package/r/markdown-editor.json +2 -2
- package/r/pi-ambient.json +5 -5
- package/r/pi-app.json +7 -7
- package/r/pi-chat.json +6 -6
- package/r/pi-workspace.json +2 -2
- package/r/provider-setup.json +2 -2
- package/r/registry.json +146 -43
- package/r/task-list-viewer.json +2 -2
- package/r/viewers.json +5 -5
package/dist/remote-chat.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export interface RemoteChatOptions {
|
|
|
7
7
|
readonly endpoint: string | URL;
|
|
8
8
|
/** The host adds credentials here. */
|
|
9
9
|
readonly fetch: (request: Request) => Promise<Response>;
|
|
10
|
+
/**
|
|
11
|
+
* The current screen context, read at each submit. An object travels as the submit body's `context` (beside `requestId`, `content`
|
|
12
|
+
* and `whenBusy`) for a host whose chat transport has `requestContext`; `undefined` sends none. Advisory text for the model, never policy.
|
|
13
|
+
*/
|
|
14
|
+
readonly requestContext?: () => Readonly<Record<string, unknown>> | undefined;
|
|
10
15
|
/** Submission status poll interval for `Submission.wait`. */
|
|
11
16
|
readonly pollMs?: number;
|
|
12
17
|
/**
|
package/dist/remote-chat.js
CHANGED
|
@@ -297,7 +297,8 @@ export async function createRemoteChat(options) {
|
|
|
297
297
|
if (draft.type !== 'input' || !draft.requestId)
|
|
298
298
|
throw new Error('Remote chat submits input drafts with a request ID');
|
|
299
299
|
const requestId = draft.requestId;
|
|
300
|
-
const
|
|
300
|
+
const context = options.requestContext?.();
|
|
301
|
+
const { submissionId } = await post('submit', { requestId, content: draft.content, whenBusy: draft.whenBusy, ...(context === undefined ? {} : { context }) });
|
|
301
302
|
requests.set(submissionId, requestId);
|
|
302
303
|
const status = async () => { const record = await lookup(requestId); if (!record)
|
|
303
304
|
throw new Error('Submission is unknown to the remote host'); return record; };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"./file-tree.css": "./styles/file-tree.css"
|
|
111
111
|
},
|
|
112
112
|
"peerDependencies": {
|
|
113
|
-
"@hachej/boring-files": "^0.2.
|
|
113
|
+
"@hachej/boring-files": "^0.2.4",
|
|
114
114
|
"@earendil-works/pi-durable": "~1.1.0",
|
|
115
115
|
"react": "^19.0.0",
|
|
116
116
|
"react-dom": "^19.0.0",
|
package/r/app-shell.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "app-shell",
|
|
4
|
+
"title": "App shell: screen layout and agent layer",
|
|
5
|
+
"description": "AppShell layout \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on the shadcn drawer, sidebar, avatar and dropdown-menu of the host's own style (Base UI variants).",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
9
|
+
"@base-ui/react@^1.8.0",
|
|
10
|
+
"react@^19.0.0",
|
|
11
|
+
"react-dom@^19.0.0",
|
|
12
|
+
"lucide-react@^1.51.0"
|
|
13
|
+
],
|
|
14
|
+
"registryDependencies": [
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/feedback.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json",
|
|
19
|
+
"drawer",
|
|
20
|
+
"sidebar",
|
|
21
|
+
"avatar",
|
|
22
|
+
"dropdown-menu"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
{
|
|
26
|
+
"path": "registry/app-shell/agent-layer.tsx",
|
|
27
|
+
"content": "'use client';\n\n// The AgentLayer: ONE agent's PiChat above the app. Compact (below 768px): a bottom drawer with three fixed snap points, peek, half and\n// full (shadcn `drawer`, Base UI variant). Expanded: a right panel (`sidebar`, the sidebar-15 right-sidebar pattern). The same layer shows\n// the product manager (« Améliorer l'app ») when the account menu asks for it. Push-to-talk is an option of the agent.\n// Host concern: Pi has no layout. The chat is the borrowed NativeChatController rendered by PiChat; the screen context travels as the\n// submit's `context` (agent-context.ts). Nothing here is configurable beyond AgentSpec.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, Ref, RefObject } from 'react';\nimport { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon, Loader2Icon, MicIcon, SparklesIcon, XIcon } from 'lucide-react';\nimport { Drawer as DrawerPrimitive } from '@base-ui/react/drawer';\nimport { createVoiceCapture } from '@hachej/boring-feedback/page';\nimport type { VoiceCapture, VoiceRefusal } from '@hachej/boring-feedback/page';\nimport type { FeedbackSession } from '@hachej/boring-feedback/ui';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Drawer, DrawerContent } from '@/components/ui/drawer';\nimport { Sidebar, SidebarContent, SidebarHeader, SidebarProvider } from '@/components/ui/sidebar';\nimport { useIsMobile } from '@/hooks/use-mobile';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { NoteBubble, useComposerFeedback } from '../feedback/feedback-session';\nimport { feedbackLabelsFr } from '../feedback/feedback-labels';\nimport { PointBar, usePointing } from '../feedback/point-mode';\nimport { cn } from '../utils/utils';\nimport { useBackLayer } from './back';\nimport { appShellText } from './text';\nimport type { AppShellText } from './text';\nimport { useVisualViewport } from './use-visual-viewport';\nimport { AgentContextProvider } from './agent-context';\n\nexport interface AgentSpec {\n readonly controller: NativeChatController;\n /** The agent's name in the expanded sidebar (default: the chat's title, then « Assistant »). */\n readonly label?: string;\n /** The agent's icon in the sidebar (default: the sparkles of the header trigger). */\n readonly icon?: ComponentType<{ className?: string }>;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Push-to-talk. `send` 'auto' submits the transcript; 'draft' (default) only fills the composer. */\n readonly voice?: { readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>; readonly send?: 'auto' | 'draft' };\n /** Point mode (the product manager, in practice). */\n readonly feedback?: FeedbackSession;\n}\n\n/** Compact: closed, peek, half or full. Expanded: closed or open. */\nexport type AgentLayerState = 'closed' | 'peek' | 'half' | 'full' | 'open';\ntype Which = 'agent' | 'improve';\ntype Snap = 'closed' | 'peek' | 'half' | 'full';\n\nexport interface AgentLayerApi {\n readonly state: AgentLayerState;\n readonly showing: Which | null;\n open(which?: Which, at?: 'half' | 'full'): void;\n close(): void;\n readonly expanded: boolean;\n}\n\n// The three fixed snap points: a peek (one composer row and a line of the last reply, `PEEK_REM` above the tab bar), 55% and the full height.\nconst PEEK_REM = 6;\n/** The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60). */\nconst Z = { tabs: 20, peek: 30, layer: 40, sheet: 50, point: 60 } as const;\nconst HALF = 0.55;\nconst FULL = 1;\n/** Hold the trigger this long before it records; shorter is a tap. */\nconst HOLD_MS = 350;\n\n/**\n * What a close request from the drawer becomes: a swipe, an outside press and a lost focus never close the layer. `reason` is Base UI's\n * `details.reason` (`'swipe'`, `'outside-press'`, `'focus-out'`, `'escape-key'`, `'close-watcher'`, `'close-press'`, `'trigger-press'`, `'imperative-action'`, `'none'`).\n */\nexport function nextSnapOnClose(reason: string, hasPeek: boolean): 'peek' | 'half' | 'close' {\n return reason === 'swipe' || reason === 'outside-press' || reason === 'focus-out' ? (hasPeek ? 'peek' : 'half') : 'close';\n}\n\ntype VoicePhase = 'idle' | 'starting' | 'recording' | 'transcribing';\ninterface Voice {\n readonly phase: VoicePhase;\n readonly seconds: number;\n readonly problem: string | undefined;\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n\ninterface Internal extends AgentLayerApi {\n readonly agent: AgentSpec | undefined;\n readonly improve: AgentSpec | undefined;\n readonly snap: Snap;\n readonly setSnap: (snap: Snap) => void;\n readonly showAgent: () => void;\n /** Until this time (ms) an outside press is the AgentTrigger's own click. */\n readonly triggerUntil: { current: number };\n readonly voice: Voice;\n /** The peek height in px: `PEEK_REM` plus the tab bar the peek sits above. */\n readonly peekPoint: string;\n /** The measured tab bar height in px, given explicitly to the parts portalled to the body (they cannot inherit the shell's variables). */\n readonly tabbar: number;\n /** The persistent element holding both chats: it moves between the drawer and the panel, so closing never unmounts a chat. */\n readonly host: HTMLElement | null;\n}\n\nconst LayerContext = createContext<Internal | null>(null);\n\nfunction useInternal(): Internal {\n const layer = useContext(LayerContext);\n if (!layer) throw new Error('useAgentLayer needs an AgentLayerProvider');\n return layer;\n}\n\n/** The layer's state and controls. */\nexport function useAgentLayer(): AgentLayerApi {\n const { state, showing, open, close, expanded } = useInternal();\n return useMemo(() => ({ state, showing, open, close, expanded }), [state, showing, open, close, expanded]);\n}\n\nconst REFUSAL: Readonly<Record<VoiceRefusal, 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed'>> = {\n denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',\n};\n\n/** The agent's push-to-talk: record on `start`, transcribe on `finish`, then submit or fill the composer. One microphone capture per mount. */\nfunction useVoice(agent: AgentSpec | undefined, done: (outcome: 'sent' | 'drafted') => void): Voice {\n const [phase, setPhase] = useState<VoicePhase>('idle');\n const [seconds, setSeconds] = useState(0);\n const [problem, setProblem] = useState<string | undefined>();\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n const phaseRef = useRef<VoicePhase>('idle');\n const latest = useRef({ agent, done });\n latest.current = { agent, done };\n const set = (next: VoicePhase) => { phaseRef.current = next; setPhase(next); };\n const words = () => chatTextFor(latest.current.agent?.chatProps?.locale).labels;\n\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n useEffect(() => {\n if (phase !== 'recording') return;\n setSeconds(0);\n const started = Date.now();\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 250);\n return () => clearInterval(timer);\n }, [phase]);\n\n const start = useCallback(() => {\n if (!latest.current.agent?.voice || phaseRef.current !== 'idle') return;\n const attempt = ++run.current;\n setProblem(undefined);\n set('starting');\n capture.current ??= createVoiceCapture();\n // Called synchronously from the person's gesture, so the browser ties the permission prompt to it.\n void capture.current.start().then(result => {\n if (attempt !== run.current) { capture.current?.cancel(); return; }\n if (result.kind === 'refused') { set('idle'); setProblem(result.code ? words()[REFUSAL[result.code]] : result.reason); return; }\n set('recording');\n });\n }, []);\n\n const cancel = useCallback(() => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n work.current = null;\n setProblem(undefined);\n set('idle');\n }, []);\n\n const finish = useCallback(() => {\n const current = latest.current.agent;\n if (!current?.voice) return;\n if (phaseRef.current === 'starting') { cancel(); return; }\n if (phaseRef.current !== 'recording') return;\n const attempt = run.current;\n set('transcribing');\n void (async () => {\n const recording = await capture.current?.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { set('idle'); setProblem(words().voiceNothingHeard); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await current.voice!.transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words().voiceNothingHeard); return; }\n const fill = () => {\n const draft = current.controller.getSnapshot().draft.text;\n current.controller.setText(`${draft}${draft === '' || /\\s$/.test(draft) ? '' : ' '}${spoken}`);\n latest.current.done('drafted');\n };\n if (current.voice!.send === 'auto') {\n // Admission is awaited: the text is reported as sent only when the submission was admitted. A refusal puts it back in the composer\n // (the controller does that); a rejection (an earlier submission still unconfirmed) is caught here and the transcript is kept as a draft.\n try { if (await current.controller.send(undefined, { text: spoken })) latest.current.done('sent'); else latest.current.done('drafted'); }\n catch { fill(); }\n } else fill();\n } catch { if (attempt === run.current) setProblem(words().voiceTranscribeFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) set('idle'); }\n })();\n }, [cancel]);\n\n return useMemo(() => ({ phase, seconds, problem, start, finish, cancel }), [phase, seconds, problem, start, finish, cancel]);\n}\n\n/** The height in px of the tab bar the peek sits above (`--app-shell-tabbar` on the shell root), and one rem. */\nfunction useMeasuredPeek(probe: RefObject<HTMLDivElement | null>): { readonly peekPoint: string; readonly tabbar: number } {\n const [px, setPx] = useState(PEEK_REM * 16);\n const [tabbar, setTabbar] = useState(0);\n useLayoutEffect(() => {\n const measure = () => {\n const tab = probe.current?.offsetHeight ?? 0;\n const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;\n setPx(Math.round(PEEK_REM * rem + tab));\n setTabbar(tab);\n };\n measure();\n window.addEventListener('resize', measure);\n const observer = typeof ResizeObserver === 'undefined' || !probe.current ? undefined : new ResizeObserver(measure);\n if (observer && probe.current) observer.observe(probe.current);\n return () => { window.removeEventListener('resize', measure); observer?.disconnect(); };\n }, [probe]);\n return { peekPoint: `${px}px`, tabbar };\n}\n\n/** Places the layer in the app. `agent` is the one assistant; `improve` is the product manager. Either may be absent. */\nexport function AgentLayerProvider({ agent, improve, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: AgentSpec | undefined; readonly children: ReactNode }): ReactElement {\n const compact = useIsMobile();\n const expanded = !compact;\n const [snap, setSnapState] = useState<Snap>('closed');\n const [shown, setShown] = useState<Which>(agent ? 'agent' : 'improve');\n const probe = useRef<HTMLDivElement>(null);\n const { peekPoint, tabbar } = useMeasuredPeek(probe);\n const triggerUntil = useRef(0);\n const specs = useRef({ agent, improve });\n specs.current = { agent, improve };\n\n const setSnap = useCallback((next: Snap) => setSnapState(next), []);\n const open = useCallback((which?: Which, at: 'half' | 'full' = 'half') => {\n const { agent: a, improve: i } = specs.current;\n const target = which ?? (a ? 'agent' : 'improve');\n if (target === 'agent' ? !a : !i) return;\n setShown(target);\n setSnapState(at);\n }, []);\n const close = useCallback(() => setSnapState('closed'), []);\n const showAgent = useCallback(() => { if (specs.current.agent) setShown('agent'); }, []);\n\n // A spec that goes away takes the layer with it.\n const shownSpec = shown === 'agent' ? agent : improve;\n useEffect(() => { if (!shownSpec) { setSnapState('closed'); if (shown === 'improve' && agent) setShown('agent'); else if (shown === 'agent' && improve) setShown('improve'); } }, [shownSpec, shown, agent, improve]);\n\n // The chats live in one element that is moved into the open drawer or panel; React keeps them mounted (and so their presentation state\n // and uploads) while the layer is closed, on a phone and on a wide screen. Nothing is mounted before the layer is first opened.\n const [host] = useState(() => typeof document === 'undefined' ? null : Object.assign(document.createElement('div'), { className: 'flex min-h-0 flex-1 flex-col [&_[data-boring=pi-chat]>header]:hidden' }));\n const opened = useRef(false);\n if (snap !== 'closed') opened.current = true;\n\n const voice = useVoice(agent, outcome => {\n if (outcome === 'drafted') { setShown('agent'); setSnapState('half'); }\n });\n\n const state: AgentLayerState = snap === 'closed' ? 'closed' : expanded ? 'open' : snap;\n const value = useMemo<Internal>(() => ({\n state, showing: snap === 'closed' ? null : shown, open, close, expanded, agent, improve, snap, setSnap, showAgent, triggerUntil, voice, peekPoint, tabbar, host,\n }), [state, snap, shown, open, close, expanded, agent, improve, setSnap, showAgent, triggerUntil, voice, peekPoint, tabbar, host]);\n\n // The shell's z-scale (tabs 20, peek 30, drawer and panel 40, other sheets 50, point bar 60) as custom properties; `display: contents` keeps the box tree unchanged.\n const scale = { '--app-shell-z-tabs': Z.tabs, '--app-shell-z-peek': Z.peek, '--app-shell-z-layer': Z.layer, '--app-shell-z-sheet': Z.sheet, '--app-shell-z-point': Z.point } as CSSProperties;\n return <AgentContextProvider>\n <LayerContext.Provider value={value}>\n <div data-app-shell=\"\" className=\"contents\" style={scale}>\n <div ref={probe} aria-hidden=\"true\" className=\"pointer-events-none invisible fixed top-0 left-0 w-0\" style={{ height: 'var(--app-shell-tabbar, 0px)' }} />\n {children}\n {host && opened.current && createPortal(<Chats showing={shown} />, host)}\n </div>\n </LayerContext.Provider>\n </AgentContextProvider>;\n}\n\n/** The header button that opens the layer: a tap opens half; with `agent.voice`, press and hold records and a slide off cancels. Absent without `agent`. */\nexport function AgentTrigger({ className, children }: { readonly className?: string; readonly children?: ReactNode }) {\n const layer = useInternal();\n const { agent, voice } = layer;\n const text = appShellText(agent?.chatProps?.locale);\n const hold = useRef<{ timer: ReturnType<typeof setTimeout> | undefined; held: boolean; id: number } | null>(null);\n if (!agent) return null;\n\n const toggle = () => {\n layer.triggerUntil.current = Date.now() + 500;\n if (layer.state !== 'closed' && layer.showing === 'agent' && layer.expanded) layer.close();\n else layer.open('agent', 'half');\n };\n const release = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (event.currentTarget.hasPointerCapture?.(current.id)) event.currentTarget.releasePointerCapture(current.id);\n if (current.held) { if (event.type === 'pointerup') voice.finish(); else voice.cancel(); }\n else if (event.type === 'pointerup') toggle();\n };\n const down = (event: ReactPointerEvent<HTMLButtonElement>) => {\n if (event.button !== 0) return;\n const target = event.currentTarget;\n target.setPointerCapture?.(event.pointerId);\n const state = { timer: undefined as ReturnType<typeof setTimeout> | undefined, held: false, id: event.pointerId };\n hold.current = state;\n if (agent.voice) {\n state.timer = setTimeout(() => {\n state.held = true;\n if (layer.snap === 'closed') layer.setSnap('peek');\n layer.showAgent();\n voice.start();\n }, HOLD_MS);\n }\n };\n const move = (event: ReactPointerEvent<HTMLButtonElement>) => {\n const current = hold.current;\n if (!current?.held) return;\n const box = event.currentTarget.getBoundingClientRect();\n const slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) {\n hold.current = null;\n voice.cancel();\n }\n };\n return <button type=\"button\" data-agent-trigger=\"\" data-testid=\"agent-trigger\" aria-label={text.open} aria-expanded={layer.state !== 'closed'}\n onPointerDown={down} onPointerUp={release} onPointerCancel={release} onPointerMove={move} onContextMenu={event => event.preventDefault()}\n onClick={event => { if (event.detail === 0) toggle(); }}\n className={cn('inline-flex min-h-9 min-w-9 touch-none items-center justify-center rounded-md text-foreground outline-none select-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)}>\n {children ?? <SparklesIcon className=\"size-4\" aria-hidden=\"true\" />}\n </button>;\n}\n\nconst clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n/** Push-to-talk in the composer row (PiChat's `tools` slot): press and hold, release to send or fill; a slide off cancels. */\nfunction TalkButton({ text }: { readonly text: AppShellText }) {\n const { voice } = useInternal();\n const recording = voice.phase === 'recording' || voice.phase === 'starting';\n const busy = voice.phase === 'transcribing';\n const down = (event: ReactPointerEvent<HTMLButtonElement>) => { event.currentTarget.setPointerCapture?.(event.pointerId); voice.start(); };\n const up = (event: ReactPointerEvent<HTMLButtonElement>) => { if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); if (event.type === 'pointerup') voice.finish(); else voice.cancel(); };\n const move = (event: ReactPointerEvent<HTMLButtonElement>) => {\n if (!recording) return;\n const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) voice.cancel();\n };\n return <button type=\"button\" data-testid=\"agent-talk\" data-state={voice.phase} disabled={busy} aria-label={text.holdToTalk} title={text.holdToTalk}\n onPointerDown={down} onPointerUp={up} onPointerCancel={up} onPointerMove={move} onContextMenu={event => event.preventDefault()}\n className={cn('inline-flex size-11 shrink-0 touch-none items-center justify-center rounded-lg bg-muted text-foreground outline-none select-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-60', recording && 'bg-destructive text-white hover:bg-destructive/90')}>\n {busy ? <Loader2Icon className=\"size-5 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <MicIcon className=\"size-5\" aria-hidden=\"true\" />}\n </button>;\n}\n\n/** What went wrong with the microphone, in the flow under the title row (never over the messages). */\nfunction VoiceProblem() {\n const { voice } = useInternal();\n return voice.problem ? <p role=\"alert\" data-testid=\"agent-voice-problem\" className=\"m-0 shrink-0 px-3 pb-1 text-xs leading-4 text-destructive\">{voice.problem}</p> : null;\n}\n\n/** One agent's PiChat. It stays mounted while hidden, so its scroll, focus and drafts survive a swap with the other spec. */\nfunction SpecChat({ spec, visible, role }: { readonly spec: AgentSpec; readonly visible: boolean; readonly role: Which }) {\n // Both entries: the Feedback button and « Montrer dans l'app » (point mode, which hides the layer while the person taps the app).\n const feedback = useComposerFeedback(spec.feedback, { entry: 'both' });\n const pointing = usePointing(spec.feedback);\n const fr = spec.chatProps?.locale === 'fr';\n return <div data-spec={role} hidden={!visible} className={cn('min-h-0 min-w-0 flex-1', visible ? 'flex flex-col' : 'hidden')}>\n <PiChat {...spec.chatProps} controller={spec.controller} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { tools: <>{spec.chatProps?.tools}<TalkButton text={appShellText(spec.chatProps?.locale)} /></> } : {})} />\n {/* The note editor of ordinary feedback lives inside the layer, in the modal's focus tree; while pointing the layer steps aside and AgentLayer renders it. */}\n {/* In the flow, inside the visible content box: the popup is transformed (a fixed element would be placed against its off-screen bottom). */}\n {spec.feedback && visible && !pointing && <div className=\"shrink-0 px-2 pb-2 empty:hidden [&_[data-testid=feedback-general-bubble]]:static!\">\n <NoteBubble session={spec.feedback} {...(fr ? { labels: feedbackLabelsFr } : {})} /></div>}\n </div>;\n}\n\n/** Where the chats show: the persistent host element is moved in once, when the drawer or panel mounts. */\nfunction ChatSlot() {\n const { host } = useInternal();\n const place = useCallback((slot: HTMLDivElement | null) => { if (slot && host) slot.appendChild(host); }, [host]);\n // Coarse pointers: the chat's own transcript buttons (copy, show earlier) get the layer's 44px rule from here, without touching pi-chat.\n return <div ref={place} className=\"flex min-h-0 flex-1 flex-col [&_[data-testid=transcript]_button]:pointer-coarse:min-h-11 [&_[data-testid=transcript]_button]:pointer-coarse:min-w-11\" />;\n}\n\n/** Both specs, each mounted once it has been shown. */\nfunction Chats({ showing }: { readonly showing: Which }) {\n const { agent, improve } = useInternal();\n const seen = useRef(new Set<Which>());\n seen.current.add(showing);\n return <>\n {agent && seen.current.has('agent') && <SpecChat spec={agent} visible={showing === 'agent'} role=\"agent\" />}\n {improve && seen.current.has('improve') && <SpecChat spec={improve} visible={showing === 'improve'} role=\"improve\" />}\n </>;\n}\n\nconst ICON_BUTTON = 'inline-flex size-9 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** Title row: « ← » when the improve view has an agent to go back to, the title, then Expand or Collapse (compact only) and Close. */\nfunction Header({ text, title, compactSnap, closeRef }: { readonly text: AppShellText; readonly title: string; readonly compactSnap?: Snap; readonly closeRef?: Ref<HTMLButtonElement> }) {\n const layer = useInternal();\n const back = layer.showing === 'improve' && layer.agent;\n return <div data-testid=\"agent-layer-header\" className=\"flex shrink-0 items-center gap-1 px-2 pb-1\">\n {back && <button type=\"button\" data-testid=\"agent-layer-back\" aria-label={text.back} title={text.back} onClick={layer.showAgent} className={ICON_BUTTON}><ArrowLeftIcon className=\"size-4\" aria-hidden=\"true\" /></button>}\n <h2 data-testid=\"agent-layer-title\" className=\"m-0 min-w-0 flex-1 truncate px-1 text-sm font-semibold\">{title}</h2>\n {compactSnap === 'half' && <button type=\"button\" data-testid=\"agent-layer-expand\" aria-label={text.expand} title={text.expand} onClick={() => layer.setSnap('full')} className={ICON_BUTTON}><ChevronUpIcon className=\"size-4\" aria-hidden=\"true\" /></button>}\n {compactSnap === 'full' && <button type=\"button\" data-testid=\"agent-layer-collapse\" aria-label={text.collapse} title={text.collapse} onClick={() => layer.setSnap('half')} className={ICON_BUTTON}><ChevronDownIcon className=\"size-4\" aria-hidden=\"true\" /></button>}\n <button type=\"button\" ref={closeRef} data-testid=\"agent-layer-close\" aria-label={text.close} title={text.close} onClick={layer.close} className={ICON_BUTTON}><XIcon className=\"size-4\" aria-hidden=\"true\" /></button>\n </div>;\n}\n\nfunction useTitle(): { text: AppShellText; title: string } {\n const layer = useInternal();\n const spec = layer.showing === 'improve' ? layer.improve : layer.agent;\n const text = appShellText(spec?.chatProps?.locale);\n const title = layer.showing === 'improve' ? text.improve : (spec?.chatProps?.labels?.title ?? text.assistant);\n return { text, title };\n}\n\nconst toSnap = (point: number | string | null, peekPoint: string): Snap | null =>\n point === peekPoint ? 'peek' : point === HALF ? 'half' : point === FULL ? 'full' : null;\n\nfunction CompactSheet({ pointing }: { readonly pointing: boolean }) {\n const layer = useInternal();\n const { snap, agent, voice } = layer;\n const hasPeek = Boolean(agent);\n const { text, title } = useTitle();\n const vv = useVisualViewport();\n const closeRef = useRef<HTMLButtonElement>(null);\n const vetoed = useRef(false);\n const open = snap !== 'closed' && !pointing;\n const peek = snap === 'peek';\n const points = useMemo(() => hasPeek ? [layer.peekPoint, HALF, FULL] : [HALF, FULL], [hasPeek, layer.peekPoint]);\n const value = peek ? layer.peekPoint : snap === 'full' ? FULL : HALF;\n const showing = layer.showing ?? 'agent';\n const recording = voice.phase !== 'idle';\n\n useBackLayer(snap === 'half' || snap === 'full', layer.close, 'agent');\n\n // The popup is taller than what shows and slides down by `--drawer-snap-point-offset` (a registered, non-inheriting property, so only the\n // popup itself can read it): padding the popup by that offset makes its content box exactly the visible part. At peek the tab bar below is left out too.\n // The popup is portalled to the body, outside the shell root: it is given the measured tab bar height and the z-values explicitly.\n const style = { '--app-shell-vv-height': vv ? `${vv.height}px` : '100dvh', '--app-shell-tabbar': `${layer.tabbar}px`, '--app-shell-z-layer': Z.layer,\n paddingBottom: peek ? 'calc(var(--drawer-snap-point-offset, 0px) + var(--app-shell-tabbar, 0px))' : 'var(--drawer-snap-point-offset, 0px)' } as CSSProperties;\n // Entering the modal half or full from the non-modal peek does not rerun Base UI's initial focus: if focus is still on the page, take it to Close.\n const wasPeek = useRef(false);\n useEffect(() => {\n const entering = wasPeek.current && !peek && open;\n wasPeek.current = peek;\n if (!entering) return;\n // The element that held focus when the sheet left peek (a page control now behind the modal). Base UI re-applies its modal guards as the\n // sheet moves, so retake focus once they have settled and again after the slide, but only while focus is still stranded there (or lost to the body).\n const stranded = document.activeElement;\n const take = () => {\n const popup = closeRef.current?.closest('[data-slot=drawer-popup]');\n const active = document.activeElement;\n // Still stranded, or dropped to the body by the modal guards: retake. Focus that went anywhere else is the person's and is left alone.\n if (popup && (active === stranded || active === document.body || active === null) && !popup.contains(active)) closeRef.current?.focus({ preventScroll: true });\n };\n const early = setTimeout(take, 60), late = setTimeout(take, 520);\n return () => { clearTimeout(early); clearTimeout(late); };\n }, [peek, open]);\n return <Drawer open={open} modal={!peek} showSwipeHandle swipeDirection=\"down\" snapPoints={points} snapPoint={value}\n onOpenChange={(next, details) => {\n if (next) return;\n const outcome = nextSnapOnClose(details.reason, hasPeek);\n if (outcome === 'close') { layer.close(); return; }\n details.cancel();\n vetoed.current = true;\n setTimeout(() => { vetoed.current = false; }, 120);\n // Focus leaving the non-modal peek changes nothing, and neither does the click that finished a press on the AgentTrigger (outside the\n // drawer, so it counts as an outside press): the trigger already chose the snap.\n if (details.reason === 'focus-out' || Date.now() < layer.triggerUntil.current) return;\n layer.setSnap(outcome);\n }}\n onSnapPointChange={point => {\n // Vetoing a swipe-close makes the drawer snap back to where it was: that echo must not undo the snap we chose.\n if (vetoed.current) return;\n const next = toSnap(point, layer.peekPoint);\n if (next && (next !== 'peek' || hasPeek)) layer.setSnap(next);\n }}>\n <DrawerContent data-testid=\"agent-layer\" aria-label={title} data-state={snap} data-peek={peek} initialFocus={() => closeRef.current ?? false} style={style}\n className={cn('app-shell box-border z-(--app-shell-z-layer) data-[swipe-direction=down]:bottom-(--drawer-keyboard-inset,0px) data-[swipe-axis=y]:[--drawer-content-max-height:calc(var(--app-shell-vv-height,100dvh)-1.5rem)]',\n // The peek is the top PEEK_REM of the popup; the part that overlaps the tab bar is clipped away, so the tabs stay visible and tappable.\n 'data-[peek=true]:not-data-swiping:[clip-path:inset(0_0_calc(100%_-_6rem)_0)]')}>\n <DrawerPrimitive.VirtualKeyboardProvider>\n <div data-testid=\"agent-layer-sheet\" onFocusCapture={event => { if ((event.target as HTMLElement).matches('[data-testid=composer-input]')) layer.setSnap('full'); }}\n className={cn('relative flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain',\n // The layer's own header carries the title and Close, so the chat's is hidden. At peek: one line of the last reply above the\n // composer, no empty-state overlay, room for Close.\n '[&_[data-boring=pi-chat]>header]:hidden',\n // At peek: ONE compact row (a one-line input, the microphone and Send; the other composer controls wait for half), and the last\n // reply as a single truncated line above it.\n peek && ['[&_[data-boring=pi-chat]>.relative]:overflow-hidden [&_[data-boring=pi-chat]>.relative>.pointer-events-none]:hidden',\n '[&_[data-boring=pi-chat]>footer]:px-2 [&_[data-boring=pi-chat]>footer]:pt-1 [&_[data-boring=pi-chat]>footer]:pb-2',\n '[&_[data-testid=transcript]]:gap-0 [&_[data-testid=transcript]]:p-0 [&_[data-testid=transcript]]:px-3 [&_[data-testid=transcript]>*]:max-h-5 [&_[data-testid=transcript]>*]:truncate [&_[data-testid=transcript]_button]:hidden',\n '[&_[data-testid=composer]]:flex [&_[data-testid=composer]]:items-center [&_[data-testid=composer]]:rounded-full',\n '[&_[data-testid=composer-input]]:min-h-11 [&_[data-testid=composer-input]]:max-h-11 [&_[data-testid=composer-input]]:flex-1 [&_[data-testid=composer-input]]:px-4 [&_[data-testid=composer-input]]:py-2.5',\n '[&_[data-testid=composer]>div]:p-0 [&_[data-testid=composer]>div]:pr-1 [&_[data-testid=composer]>div>.flex-1]:hidden',\n '[&_[data-testid=composer-plus]]:hidden [&_[data-testid=composer-feedback]]:hidden [&_[data-testid=composer-point]]:hidden'])}>\n {!peek && <Header text={text} title={title} compactSnap={snap} closeRef={closeRef} />}\n {!peek && <VoiceProblem />}\n {peek && recording && <div role=\"status\" data-testid=\"agent-listening\" data-phase={voice.phase} className=\"absolute inset-0 z-20 flex items-center justify-center gap-2 bg-popover text-sm font-medium\">\n <MicIcon className=\"size-4 text-destructive\" aria-hidden=\"true\" />{voice.phase === 'transcribing' ? <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : `${text.listening} ${clock(voice.seconds)}`}\n </div>}\n {/* The transcript scrolls and never drags the sheet: only the handle and the title row swipe. */}\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col\"><ChatSlot /></div>\n </div>\n </DrawerPrimitive.VirtualKeyboardProvider>\n </DrawerContent>\n </Drawer>;\n}\n\nfunction ExpandedPanel() {\n const layer = useInternal();\n const { text, title } = useTitle();\n const open = layer.snap !== 'closed';\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && !event.defaultPrevented) layer.close(); };\n document.addEventListener('keydown', onKey);\n return () => document.removeEventListener('keydown', onKey);\n }, [open, layer]);\n // Its own provider keeps the panel's width and open state apart from the left sidebar's (Ctrl+B belongs to that one); `contents` adds no box.\n return <SidebarProvider open={open} onOpenChange={() => {}} className=\"contents\" style={{ '--sidebar-width': '24rem' } as CSSProperties}>\n <Sidebar side=\"right\" collapsible=\"offcanvas\" data-testid=\"agent-layer\" data-state={open ? 'open' : 'closed'} aria-label={title} inert={!open} className=\"app-shell z-(--app-shell-z-layer)\">\n <SidebarHeader className=\"p-0 pt-2\"><Header text={text} title={title} /></SidebarHeader>\n <SidebarContent className=\"gap-0 overflow-hidden\">\n <VoiceProblem />\n <ChatSlot />\n </SidebarContent>\n </Sidebar>\n </SidebarProvider>;\n}\n\n/** The layer itself: the drawer on a phone, the right panel on a wide screen. Placed once by the shell. */\nexport function AgentLayer(): ReactElement | null {\n const layer = useInternal();\n const pointingAgent = usePointing(layer.agent?.feedback), pointingImprove = usePointing(layer.improve?.feedback);\n const session = pointingAgent ? layer.agent?.feedback : pointingImprove ? layer.improve?.feedback : undefined;\n // Back ends pointing first (the `point` priority); the layer keeps its own history entry underneath.\n useBackLayer(session !== undefined, () => session?.discard(), 'point');\n if (!layer.agent && !layer.improve) return null;\n const locale = (pointingAgent ? layer.agent : layer.improve)?.chatProps?.locale;\n // Point mode: the compact sheet steps aside (the wide panel does not cover the app); the bar sits above the tab bar, outside the layer.\n return <>\n {layer.expanded ? <ExpandedPanel /> : <CompactSheet pointing={session !== undefined} />}\n {session && <>\n <PointBar session={session} bottomOffset={`${layer.tabbar}px`} className=\"z-[60]\" {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n <NoteBubble session={session} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n </>}\n </>;\n}\n",
|
|
28
|
+
"type": "registry:component",
|
|
29
|
+
"target": "components/app-shell/agent-layer.tsx"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "registry/app-shell/agent-context.ts",
|
|
33
|
+
"content": "// What the person is looking at, for the agent. A screen calls `useAgentContext({ name, params })`; the transport sends the latest\n// value with each submit (`createRemoteChat({ requestContext: readAgentContext })`) and the host renders it in `createPromptContext`.\n// Advisory text for the model, never policy. The most recently mounted caller wins; unmounting restores the previous one.\nimport { createContext, createElement, useContext, useEffect, useLayoutEffect, useRef } from 'react';\nimport type { ReactNode } from 'react';\n\n/** A type alias, so the value is assignable to the transport's `Record<string, unknown>`. */\nexport type AgentScreenContext = { readonly name: string; readonly params: Readonly<Record<string, unknown>> };\n\ninterface Entry { value: AgentScreenContext }\ninterface Store { readonly entries: Entry[] }\n\nconst StoreContext = createContext<Store | null>(null);\n/**\n * Whether the calling screen is the one on show. The shell keeps every screen mounted and hides the others, so a hidden screen must not\n * register its context (here) or its header (`useScreenHeader`). True outside a shell.\n */\nexport const ScreenActiveContext = createContext(true);\n/** The mounted provider's store, for `readAgentContext` (the transport option has no React context to read). */\nlet current: Store | null = null;\n\n/** Holds the screens' contexts. `AgentLayerProvider` mounts one. */\nexport function AgentContextProvider({ children }: { readonly children: ReactNode }) {\n const store = useRef<Store>({ entries: [] });\n useLayoutEffect(() => {\n const mine = store.current;\n current = mine;\n return () => { if (current === mine) current = null; };\n }, []);\n return createElement(StoreContext.Provider, { value: store.current }, children);\n}\n\n/** Registers the current screen while the calling component is mounted; a new `name` or `params` (compared as JSON) replaces it. */\nexport function useAgentContext(value: AgentScreenContext): void {\n const store = useContext(StoreContext);\n const active = useContext(ScreenActiveContext);\n const key = JSON.stringify([value.name, value.params]);\n const latest = useRef(value);\n latest.current = value;\n const entry = useRef<Entry>({ value });\n // Mounting takes the top place; a later change of `name` or `params` updates the entry where it stands.\n useEffect(() => {\n if (!store || !active) return;\n const mine = entry.current;\n store.entries.push(mine);\n return () => { const at = store.entries.indexOf(mine); if (at >= 0) store.entries.splice(at, 1); };\n }, [store, active]);\n useEffect(() => { entry.current.value = latest.current; }, [key]);\n}\n\n/** The latest screen context, or undefined when no screen registered one. Pass it as `requestContext`. */\nexport function readAgentContext(): AgentScreenContext | undefined {\n const entries = current?.entries;\n return entries?.length ? entries[entries.length - 1]!.value : undefined;\n}\n",
|
|
34
|
+
"type": "registry:lib",
|
|
35
|
+
"target": "components/app-shell/agent-context.ts"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "registry/app-shell/back.ts",
|
|
39
|
+
"content": "// The one Back manager of the app shell. A layer that is open (the agent sheet, another sheet, point mode, a pushed screen) asks for one\n// history entry with the same URL; the browser's Back closes the topmost layer instead of leaving the page, and closing a layer any\n// other way removes its entry, so the history stays as it was. Hosts: the AgentLayer (order `agent`) and, later, sheets and stacks.\nimport { useEffect, useRef } from 'react';\n\n/** The fixed priority of Back: `point` is handled first, then an open `sheet`, then the `agent` layer, then the `stack`, then the browser. */\nexport type BackOrder = 'point' | 'sheet' | 'agent' | 'stack';\nconst RANK: Readonly<Record<BackOrder, number>> = { point: 0, sheet: 1, agent: 2, stack: 3 };\n\ninterface Layer { readonly order: BackOrder; readonly close: () => void; popped: boolean }\n\nconst layers = new Set<Layer>();\n/** `popstate` events caused by our own `history.back()`, which must not close anything. */\nlet ignored = 0;\nlet listening = false;\n/** Layers that opened while our own `history.back()` was still pending: they push their entry once it has landed (a push in between cancels it). */\nconst waiting: (() => void)[] = [];\n\n/**\n * Runs `action` once the history entry of a sheet that is closing has really gone. A choice that navigates (the app's router replaces the\n * URL) must not run before: the sheet's own `history.back()` would undo it. Call it in the same handler that closes the sheet.\n */\nexport function afterBack(action: () => void): void {\n // The sheet's effect cleanup (which starts its `history.back()`) runs after this handler returns.\n setTimeout(() => { if (ignored > 0) waiting.push(action); else action(); }, 0);\n}\n\nconst topmost = (): Layer | undefined => [...layers].sort((a, b) => RANK[a.order] - RANK[b.order])[0];\n\nfunction onPopState(): void {\n if (ignored > 0) {\n ignored -= 1;\n if (ignored === 0) for (const start of waiting.splice(0)) start();\n return;\n }\n const top = topmost();\n if (!top) return;\n // The entry is already gone: the layer closes without calling `history.back()` again.\n top.popped = true;\n top.close();\n}\n\n/**\n * While `open`, the layer owns one history entry (`history.pushState({ appShell: order }, '')`, same URL). `popstate` closes only the\n * topmost open layer. Any other close (the effect ends because `open` became false or the component unmounted) calls `history.back()`\n * once, and that `popstate` is ignored. `close` may change between renders.\n */\nexport function useBackLayer(open: boolean, close: () => void, order: BackOrder): void {\n const latest = useRef(close);\n latest.current = close;\n useEffect(() => {\n if (!open) return;\n const layer: Layer = { order, close: () => latest.current(), popped: false };\n let started = false;\n const start = () => {\n started = true;\n history.pushState({ appShell: order }, '');\n layers.add(layer);\n };\n if (!listening) { window.addEventListener('popstate', onPopState); listening = true; }\n // A sheet that closes as this layer opens (the account menu choosing « Améliorer l'app ») is still going back: wait for it.\n if (ignored > 0) waiting.push(start); else start();\n return () => {\n const at = waiting.indexOf(start);\n if (at >= 0) waiting.splice(at, 1);\n if (!started) return;\n layers.delete(layer);\n if (!layer.popped) { ignored += 1; history.back(); }\n };\n }, [open, order]);\n}\n",
|
|
40
|
+
"type": "registry:lib",
|
|
41
|
+
"target": "components/app-shell/back.ts"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "registry/app-shell/use-visual-viewport.ts",
|
|
45
|
+
"content": "import { useEffect, useState } from 'react';\n\nexport interface VisualViewportBox { readonly top: number; readonly height: number }\n\n/**\n * The visual viewport (what is left above the software keyboard), updated on its `resize` and `scroll`. Null where the browser has none.\n * While the page is pinch-zoomed (`scale > 1.01`) the last value is kept, so zooming never resizes a layer.\n */\nexport function useVisualViewport(): VisualViewportBox | null {\n const [box, setBox] = useState<VisualViewportBox | null>(null);\n useEffect(() => {\n const viewport = window.visualViewport;\n if (!viewport) return;\n const read = () => {\n if (viewport.scale > 1.01) return;\n const next = { top: viewport.offsetTop, height: viewport.height };\n setBox(previous => previous && previous.top === next.top && previous.height === next.height ? previous : next);\n };\n read();\n viewport.addEventListener('resize', read);\n viewport.addEventListener('scroll', read);\n return () => { viewport.removeEventListener('resize', read); viewport.removeEventListener('scroll', read); };\n }, []);\n return box;\n}\n",
|
|
46
|
+
"type": "registry:hook",
|
|
47
|
+
"target": "components/app-shell/use-visual-viewport.ts"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "registry/app-shell/text.ts",
|
|
51
|
+
"content": "// The app shell's own words (the AgentLayer's and the screen layout's). The language is the agent's chat language (`agent.chatProps.locale`), not a prop of the layer; this follows\n// pi-chat's labels.ts / labels-fr.ts pattern (a fixed set of keys per language, checked to have the same keys).\nexport type AppShellLocale = 'en' | 'fr';\n\nexport interface AppShellText {\n readonly close: string;\n readonly expand: string;\n readonly collapse: string;\n /** « ← » back to the assistant from the improve view. */\n readonly back: string;\n readonly listening: string;\n /** The account-menu entry that opens the product manager in the layer. */\n readonly improve: string;\n /** The hero button: hold it and talk. */\n readonly holdToTalk: string;\n /** The title of the agent when the host gives the chat none. */\n readonly assistant: string;\n /** The header trigger that opens the layer. */\n readonly open: string;\n /** The fourth tab when there are more than four screens, and the title of its sheet. */\n readonly more: string;\n readonly signOut: string;\n /** The account sheet's title when the account has no name. */\n readonly account: string;\n /** The header's « … » button. */\n readonly actions: string;\n /** The label of the tab bar. */\n readonly tabs: string;\n /** The « ← » of a header. */\n readonly goBack: string;\n}\n\nconst en: AppShellText = {\n close: 'Close',\n expand: 'Expand',\n collapse: 'Collapse',\n back: 'Back to the assistant',\n listening: 'Listening…',\n improve: 'Improve the app',\n holdToTalk: 'Hold and talk',\n assistant: 'Assistant',\n open: 'Open the assistant',\n more: 'More',\n signOut: 'Sign out',\n account: 'Account',\n actions: 'More actions',\n tabs: 'Navigation',\n goBack: 'Back',\n};\n\nconst fr: AppShellText = {\n close: 'Fermer',\n expand: 'Agrandir',\n collapse: 'Réduire',\n back: 'Retour à l\\'assistant',\n listening: 'J\\'écoute…',\n improve: 'Améliorer l\\'app',\n holdToTalk: 'Appuie et parle',\n assistant: 'Assistant',\n open: 'Ouvrir l\\'assistant',\n more: 'Plus',\n signOut: 'Se déconnecter',\n account: 'Compte',\n actions: 'Plus d\\'actions',\n tabs: 'Navigation',\n goBack: 'Retour',\n};\n\nconst TEXT: Readonly<Record<AppShellLocale, AppShellText>> = { en, fr };\n\n/** The layer's words for `locale` (English by default). */\nexport const appShellText = (locale: AppShellLocale | undefined): AppShellText => TEXT[locale ?? 'en'];\n",
|
|
52
|
+
"type": "registry:lib",
|
|
53
|
+
"target": "components/app-shell/text.ts"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "registry/app-shell/app-shell.tsx",
|
|
57
|
+
"content": "'use client';\n\n// The app shell, layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »). Expanded:\n// the shadcn sidebar (icon rail plus NavUser). One header per screen (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';\nimport { AccountAvatar, AccountMenu } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './sheet';\nimport { appShellText } from './text';\nimport type { AppShellText } from './text';\n\nexport interface AppShellProps {\n /** Layout A is `screen`; `workspace` and `chat` come later. */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n readonly agent?: AgentSpec;\n /** The product manager: adds « Améliorer l'app » to the account menu. */\n readonly improve?: AgentSpec;\n readonly account?: AccountSpec;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId }: { readonly items: readonly NavItem[]; readonly activeId: string }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className=\"h-full overflow-y-auto overscroll-contain\"><ScreenBody item={item} /></div>\n </ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ title, nav, agent, improve, account, route, text }: { readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly improve: boolean; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const compact = useIsCompact();\n const [accountOpen, setAccountOpen] = useState(false);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <button type=\"button\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} onClick={() => setAccountOpen(true)}\n className=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:min-h-11 pointer-coarse:min-w-11\"><AccountAvatar account={account} /></button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={account?.name ?? text.account}>\n <AccountMenu account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" className=\"h-dvh min-h-0 overflow-hidden\">\n {!compact && <AppSidebar title={title} items={nav} activeId={active.id} onChange={route.onChange} agent={agent} account={account} improve={improve} text={text} />}\n <SidebarInset className=\"min-h-0 min-w-0 overflow-hidden\">\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} text={text} />\n <Screens items={nav} activeId={active.id} />\n {compact && <TabBar items={nav} activeId={active.id} onChange={route.onChange} text={text} />}\n </SidebarInset>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n if (props.layout !== 'screen') throw new Error(`AppShell layout \"${props.layout}\" is not implemented`);\n const { agent, improve, title, nav, account, route } = props;\n const text = appShellText((agent ?? improve)?.chatProps?.locale);\n return <AgentLayerProvider agent={agent} improve={improve}>\n <HeaderProvider><Shell title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
|
|
58
|
+
"type": "registry:component",
|
|
59
|
+
"target": "components/app-shell/app-shell.tsx"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "registry/app-shell/header.tsx",
|
|
63
|
+
"content": "'use client';\n\n// One header per screen. A screen calls `useScreenHeader` and the shell renders it; the most recently mounted caller wins and unmounting\n// restores the previous one. A screen the shell has hidden does not count (ScreenActiveContext). Host concern: Pi has no layout.\nimport { createContext, createElement, useContext, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { ArrowLeftIcon, EllipsisIcon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { cn } from '../utils/utils';\nimport { ScreenActiveContext } from './agent-context';\nimport { afterBack } from './back';\nimport { AppSheet, SHEET_ROW } from './sheet';\nimport { AgentTrigger } from './agent-layer';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\nexport interface ScreenHeaderAction {\n readonly id: string;\n /** The accessible name and the tooltip. */\n readonly label: string;\n readonly icon: ComponentType<Icon>;\n /** `'always'` also shows the label as text next to the icon (default: icon only, at every width). */\n readonly showLabel?: 'always';\n readonly onSelect: () => void;\n}\nexport interface ScreenHeaderSpec {\n /** Default: the active nav label. */\n readonly title?: string;\n readonly subtitle?: string;\n /** Shows « ← ». */\n readonly back?: () => void;\n /** At most two are shown; the others go in « … ». */\n readonly actions?: readonly ScreenHeaderAction[];\n}\n\ninterface Entry { spec: ScreenHeaderSpec }\ninterface Store {\n readonly entries: Entry[];\n version: number;\n readonly listeners: Set<() => void>;\n}\n\nconst HeaderContext = createContext<Store | null>(null);\n\n/** A number per icon component, so a change of icon is a change of header. */\nconst icons = new WeakMap<object, number>();\nlet iconCount = 0;\nconst iconId = (icon: ComponentType<Icon>): number => { let id = icons.get(icon); if (id === undefined) { iconCount += 1; id = iconCount; icons.set(icon, id); } return id; };\n\nconst touch = (store: Store) => { store.version += 1; for (const listener of [...store.listeners]) listener(); };\n\n/** Holds the screens' headers. `AppShell` mounts one. */\nexport function HeaderProvider({ children }: { readonly children: ReactNode }) {\n const store = useRef<Store>({ entries: [], version: 0, listeners: new Set() });\n return createElement(HeaderContext.Provider, { value: store.current }, children);\n}\n\n/** Registers the calling screen's header while it is mounted and on show. The latest spec is always read at click time. */\nexport function useScreenHeader(spec: ScreenHeaderSpec): void {\n const store = useContext(HeaderContext);\n const active = useContext(ScreenActiveContext);\n const entry = useRef<Entry>({ spec });\n entry.current.spec = spec;\n const key = JSON.stringify([spec.title, spec.subtitle, Boolean(spec.back), spec.actions?.map(action => [action.id, action.label, action.showLabel, iconId(action.icon)])]);\n useLayoutEffect(() => {\n if (!store || !active) return;\n const mine = entry.current;\n store.entries.push(mine);\n touch(store);\n return () => { const at = store.entries.indexOf(mine); if (at >= 0) store.entries.splice(at, 1); touch(store); };\n }, [store, active]);\n useLayoutEffect(() => { if (store && active) touch(store); }, [store, active, key]);\n}\n\n/** The header of the screen on show (the most recently mounted caller), or undefined. Re-renders when screens come and go or change their header. */\nexport function useTopHeader(): { readonly spec: ScreenHeaderSpec } | undefined {\n const store = useContext(HeaderContext);\n useSyncExternalStore(\n listener => { store?.listeners.add(listener); return () => { store?.listeners.delete(listener); }; },\n () => store?.version ?? 0,\n () => 0,\n );\n return store?.entries.at(-1);\n}\n\nconst ICON_BUTTON = 'inline-flex size-9 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:min-h-11 pointer-coarse:min-w-11';\nconst LABEL_BUTTON = 'inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border border-border px-3 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 pointer-coarse:min-h-11';\n\n/**\n * The header of the screen on show: the avatar button (compact) or the sidebar trigger (expanded) or « ← », then the title and subtitle,\n * then up to two actions, « … » and the agent trigger. `avatar` is the compact account button.\n */\nexport function ScreenHeader({ fallbackTitle, compact, avatar, text }: { readonly fallbackTitle: string; readonly compact: boolean; readonly avatar: ReactNode; readonly text: AppShellText }) {\n const top = useTopHeader();\n const spec = top?.spec;\n const actions = spec?.actions ?? [];\n const shown = actions.slice(0, 2), extra = actions.slice(2);\n const [sheetOpen, setSheetOpen] = useState(false);\n const run = (id: string) => top?.spec.actions?.find(action => action.id === id)?.onSelect();\n const back = spec?.back ? <button type=\"button\" data-testid=\"header-back\" aria-label={text.goBack} onClick={() => top?.spec.back?.()} className={ICON_BUTTON}><ArrowLeftIcon className=\"size-4\" aria-hidden=\"true\" /></button> : null;\n return <header data-testid=\"screen-header\" className=\"sticky top-0 z-10 flex min-h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2 pt-[env(safe-area-inset-top)]\">\n {compact ? (back ?? avatar) : <><SidebarTrigger className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />{back}</>}\n <div className=\"min-w-0 flex-1 px-1\">\n <h1 data-testid=\"header-title\" className=\"m-0 truncate text-base leading-tight font-semibold [font-family:var(--font-display,var(--font-sans))]\">{spec?.title ?? fallbackTitle}</h1>\n {spec?.subtitle && <p data-testid=\"header-subtitle\" className=\"m-0 truncate text-xs text-muted-foreground\">{spec.subtitle}</p>}\n </div>\n {shown.map(action => <button key={action.id} type=\"button\" data-testid={`header-action-${action.id}`} aria-label={action.label} title={action.label} onClick={() => run(action.id)}\n className={action.showLabel === 'always' ? LABEL_BUTTON : ICON_BUTTON}><action.icon className=\"size-4\" />{action.showLabel === 'always' && action.label}</button>)}\n {extra.length > 0 && (compact\n ? <>\n <button type=\"button\" data-testid=\"header-more\" aria-label={text.actions} title={text.actions} onClick={() => setSheetOpen(true)} className={ICON_BUTTON}><EllipsisIcon className=\"size-4\" aria-hidden=\"true\" /></button>\n <AppSheet open={sheetOpen} onOpenChange={setSheetOpen} title={text.actions}>\n {extra.map(action => <button key={action.id} type=\"button\" className={SHEET_ROW} onClick={() => { setSheetOpen(false); afterBack(() => run(action.id)); }}><action.icon className=\"size-4\" />{action.label}</button>)}\n </AppSheet>\n </>\n : <DropdownMenu>\n <DropdownMenuTrigger data-testid=\"header-more\" aria-label={text.actions} className={cn(ICON_BUTTON, 'data-popup-open:bg-accent')}><EllipsisIcon className=\"size-4\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-48\">\n {extra.map(action => <DropdownMenuItem key={action.id} className=\"pointer-coarse:min-h-11\" onClick={() => run(action.id)}><action.icon className=\"size-4\" />{action.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenu>)}\n <AgentTrigger />\n </header>;\n}\n",
|
|
64
|
+
"type": "registry:component",
|
|
65
|
+
"target": "components/app-shell/header.tsx"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "registry/app-shell/nav.tsx",
|
|
69
|
+
"content": "'use client';\n\n// The navigation: a bottom tab bar when compact, the shadcn sidebar (icon rail plus NavUser, the sidebar-07 and sidebar-08 patterns) when\n// expanded. Choosing an item only calls `route.onChange`: the app's router owns the URL, and the shell adds no history entry.\nimport { useEffect, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { EllipsisIcon, SparklesIcon } from 'lucide-react';\nimport { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator } from '@/components/ui/sidebar';\nimport { cn } from '../utils/utils';\nimport { afterBack } from './back';\nimport { NavUser } from './account';\nimport { useAgentLayer } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport type { AccountSpec } from './account';\nimport { AppSheet, SHEET_ROW } from './sheet';\nimport type { AppShellText } from './text';\n\nexport interface NavItem {\n readonly id: string;\n readonly label: string;\n readonly icon: ComponentType<{ className?: string }>;\n readonly badge?: boolean;\n readonly render: () => ReactNode;\n}\n\n/** The most tabs the bar shows. */\nconst MAX_TABS = 4;\n\n/** Up to four items are all tabs; more than four give three tabs and the rest behind « Plus ». */\nexport function splitNav<T>(items: readonly T[]): { readonly tabs: readonly T[]; readonly overflow: readonly T[] } {\n return items.length <= MAX_TABS ? { tabs: items, overflow: [] } : { tabs: items.slice(0, MAX_TABS - 1), overflow: items.slice(MAX_TABS - 1) };\n}\n\nconst Dot = ({ className }: { readonly className?: string }) => <span aria-hidden=\"true\" data-testid=\"nav-badge\" className={cn('size-2 rounded-full bg-primary', className)} />;\n\n/** Publishes the bar's measured height as `--app-shell-tabbar` on the document root (the drawer is portaled, so the shell root is too high up to reach it). */\nfunction useTabbarHeight(bar: { readonly current: HTMLElement | null }, shown: boolean): void {\n useEffect(() => {\n const root = document.documentElement;\n const node = bar.current;\n if (!shown || !node) { root.style.setProperty('--app-shell-tabbar', '0px'); return; }\n const measure = () => root.style.setProperty('--app-shell-tabbar', `${node.getBoundingClientRect().height}px`);\n measure();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : undefined;\n observer?.observe(node);\n return () => { observer?.disconnect(); root.style.removeProperty('--app-shell-tabbar'); };\n }, [bar, shown]);\n}\n\nconst TAB = 'relative flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 px-1 text-xs outline-none focus-visible:bg-accent pointer-coarse:min-h-14';\n\n/** The compact tab bar. With one item there is none. Exported for tests. */\nexport function TabBar({ items, activeId, onChange, text }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void; readonly text: AppShellText }) {\n const bar = useRef<HTMLElement>(null);\n const [plusOpen, setPlusOpen] = useState(false);\n const shown = items.length > 1;\n useTabbarHeight(bar, shown);\n if (!shown) return null;\n const { tabs, overflow } = splitNav(items);\n const inOverflow = overflow.find(item => item.id === activeId);\n const arrows = (event: KeyboardEvent<HTMLElement>) => {\n const buttons = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')];\n const at = buttons.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (at < 0 || !step) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]!.focus();\n };\n const tab = (item: NavItem) => <button key={item.id} type=\"button\" role=\"tab\" data-testid={`tab-${item.id}`} aria-selected={item.id === activeId} tabIndex={item.id === activeId ? 0 : -1}\n onClick={() => onChange(item.id)} className={cn(TAB, item.id === activeId ? 'text-primary' : 'text-muted-foreground')}>\n <item.icon className=\"size-5\" />{item.label}{item.badge && <Dot className=\"absolute top-2 left-1/2 ml-2\" />}\n </button>;\n const Plus = overflow.length ? (inOverflow?.icon ?? EllipsisIcon) : undefined;\n return <nav ref={bar} role=\"tablist\" aria-label={text.tabs} data-testid=\"tabbar\" data-feedback-ignore=\"\" onKeyDown={arrows}\n className=\"relative z-(--app-shell-z-tabs) box-border flex shrink-0 border-t border-border bg-background pb-[env(safe-area-inset-bottom)]\">\n {tabs.map(tab)}\n {Plus && <>\n <button type=\"button\" role=\"tab\" data-testid=\"tab-plus\" aria-selected={Boolean(inOverflow)} aria-haspopup=\"dialog\" tabIndex={inOverflow ? 0 : -1} onClick={() => setPlusOpen(true)}\n className={cn(TAB, inOverflow ? 'text-primary' : 'text-muted-foreground')}>\n <Plus className=\"size-5\" />{inOverflow?.label ?? text.more}{!inOverflow && overflow.some(item => item.badge) && <Dot className=\"absolute top-2 left-1/2 ml-2\" />}\n </button>\n <AppSheet open={plusOpen} onOpenChange={setPlusOpen} title={text.more}>\n {overflow.map(item => <button key={item.id} type=\"button\" data-testid={`plus-${item.id}`} aria-current={item.id === activeId} className={cn(SHEET_ROW, item.id === activeId && 'text-primary')}\n onClick={() => { setPlusOpen(false); afterBack(() => onChange(item.id)); }}>\n <item.icon className=\"size-5\" />{item.label}{item.badge && <Dot className=\"ml-auto\" />}\n </button>)}\n </AppSheet>\n </>}\n </nav>;\n}\n\n/** The expanded sidebar: the app title, the nav items (none with one item) and the account in the footer. */\nexport function AppSidebar({ title, items, activeId, onChange, agent, account, improve, text }: {\n readonly title: string; readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void;\n readonly agent: AgentSpec | undefined; readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText;\n}) {\n const layer = useAgentLayer();\n const pressed = layer.state !== 'closed' && layer.showing === 'agent';\n const AgentIcon = agent?.icon ?? SparklesIcon;\n const agentLabel = agent?.label ?? agent?.chatProps?.labels?.title ?? text.assistant;\n return <Sidebar collapsible=\"icon\" variant=\"inset\" data-testid=\"app-sidebar\">\n <SidebarHeader><p data-testid=\"sidebar-title\" className=\"m-0 truncate px-2 py-1 text-base font-semibold group-data-[collapsible=icon]:hidden\">{title}</p></SidebarHeader>\n <SidebarContent>\n {items.length > 1 && <SidebarGroup><SidebarGroupContent><SidebarMenu>\n {items.map(item => <SidebarMenuItem key={item.id}>\n <SidebarMenuButton isActive={item.id === activeId} tooltip={item.label} data-testid={`nav-${item.id}`} className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" onClick={() => onChange(item.id)}>\n <item.icon />\n <span>{item.label}</span>\n {item.badge && <Dot className=\"ml-auto group-data-[collapsible=icon]:hidden\" />}\n </SidebarMenuButton>\n </SidebarMenuItem>)}\n </SidebarMenu></SidebarGroupContent></SidebarGroup>}\n {/* The assistant is a layer, never a destination: no route, it toggles the right panel. */}\n {agent && <>\n {items.length > 1 && <SidebarSeparator />}\n <SidebarGroup><SidebarGroupContent><SidebarMenu><SidebarMenuItem>\n <SidebarMenuButton isActive={pressed} aria-pressed={pressed} tooltip={agentLabel} data-testid=\"nav-agent\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n onClick={() => { if (pressed) layer.close(); else layer.open('agent', 'half'); }}>\n <AgentIcon /><span>{agentLabel}</span>\n </SidebarMenuButton>\n </SidebarMenuItem></SidebarMenu></SidebarGroupContent></SidebarGroup>\n </>}\n </SidebarContent>\n <SidebarFooter><NavUser account={account} improve={improve} text={text} /></SidebarFooter>\n <SidebarRail />\n </Sidebar>;\n}\n",
|
|
70
|
+
"type": "registry:component",
|
|
71
|
+
"target": "components/app-shell/nav.tsx"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"path": "registry/app-shell/account.tsx",
|
|
75
|
+
"content": "'use client';\n\n// The account menu: the compact sheet's content (`AccountMenu`) and the expanded sidebar's footer (`NavUser`, the sidebar-07 pattern).\n// Both list « Améliorer l'app » (when the app has an `improve` agent, opened in the AgentLayer), the app's own `menu`, then sign out.\nimport { ChevronsUpDownIcon, LogOutIcon, SparklesIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { useAgentLayer } from './agent-layer';\nimport { afterBack } from './back';\nimport { SHEET_ROW } from './sheet';\nimport type { AppShellText } from './text';\n\nexport interface AccountSpec {\n readonly name: string;\n readonly email?: string;\n readonly signOut?: () => void;\n readonly menu: readonly { readonly label: string; readonly onSelect: () => void }[];\n}\n\n/** « Jean Dupont » gives « JD »; one word gives its first letter. */\nexport function initials(name: string): string {\n const letters = name.trim().split(/\\s+/).filter(Boolean).slice(0, 2).map(word => [...word][0]!.toUpperCase());\n return letters.join('') || '?';\n}\n\nexport function AccountAvatar({ account, size }: { readonly account: AccountSpec | undefined; readonly size?: 'default' | 'lg' }) {\n return <Avatar {...(size ? { size } : {})}><AvatarFallback>{initials(account?.name ?? '')}</AvatarFallback></Avatar>;\n}\n\n/** The compact account sheet's content. Every choice closes the sheet first (`done`). */\nexport function AccountMenu({ account, improve, text, done }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText; readonly done: () => void }) {\n const layer = useAgentLayer();\n const choose = (action: () => void) => () => { done(); afterBack(action); };\n return <div data-testid=\"account-menu\">\n {account && <div className=\"flex items-center gap-3 px-4 py-3\">\n <AccountAvatar account={account} size=\"lg\" />\n <div className=\"min-w-0\"><p className=\"m-0 truncate font-medium\">{account.name}</p>{account.email && <p className=\"m-0 truncate text-xs text-muted-foreground\">{account.email}</p>}</div>\n </div>}\n {improve && <button type=\"button\" data-testid=\"account-improve\" className={SHEET_ROW} onClick={choose(() => layer.open('improve'))}><SparklesIcon className=\"size-4\" aria-hidden=\"true\" />{text.improve}</button>}\n {account?.menu.map(entry => <button key={entry.label} type=\"button\" className={SHEET_ROW} onClick={choose(entry.onSelect)}>{entry.label}</button>)}\n {account?.signOut && <button type=\"button\" data-testid=\"account-sign-out\" className={SHEET_ROW} onClick={choose(account.signOut)}><LogOutIcon className=\"size-4\" aria-hidden=\"true\" />{text.signOut}</button>}\n </div>;\n}\n\n/** The expanded sidebar footer: avatar, name and a dropdown with the same entries as the compact sheet. */\nexport function NavUser({ account, improve, text }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText }) {\n const layer = useAgentLayer();\n if (!account && !improve) return null;\n return <SidebarMenu><SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger render={<SidebarMenuButton size=\"lg\" data-testid=\"nav-user\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground\" />}>\n <AccountAvatar account={account} />\n <div className=\"grid min-w-0 flex-1 text-left text-sm leading-tight\">\n <span className=\"truncate font-medium\">{account?.name ?? text.account}</span>\n {account?.email && <span className=\"truncate text-xs text-muted-foreground\">{account.email}</span>}\n </div>\n <ChevronsUpDownIcon className=\"ml-auto size-4\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"end\" sideOffset={4} className=\"w-56 min-w-56\">\n {improve && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-improve\" onClick={() => layer.open('improve')}><SparklesIcon className=\"size-4\" aria-hidden=\"true\" />{text.improve}</DropdownMenuItem>}\n {account?.menu.map(entry => <DropdownMenuItem key={entry.label} className=\"pointer-coarse:min-h-11\" onClick={entry.onSelect}>{entry.label}</DropdownMenuItem>)}\n {account?.signOut && <><DropdownMenuSeparator /><DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-sign-out\" onClick={account.signOut}><LogOutIcon className=\"size-4\" aria-hidden=\"true\" />{text.signOut}</DropdownMenuItem></>}\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem></SidebarMenu>;\n}\n",
|
|
76
|
+
"type": "registry:component",
|
|
77
|
+
"target": "components/app-shell/account.tsx"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "registry/app-shell/sheet.tsx",
|
|
81
|
+
"content": "'use client';\n\n// The one sheet of the shell (account, « Plus », « … », app flows): the shadcn `drawer`, a modal bottom sheet. Only one is open at a time\n// (opening one closes any other) and Back closes it (the Back manager's `sheet` layer). The agent layer is not an AppSheet.\nimport { useEffect, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { useBackLayer } from './back';\n\ninterface Open { readonly close: () => void }\nconst opened = new Set<Open>();\n\n/** While `open`, this sheet is the only one: every other open sheet is asked to close when it opens. */\nexport function useExclusiveSheet(open: boolean, close: () => void): void {\n const latest = useRef(close);\n latest.current = close;\n useEffect(() => {\n if (!open) return;\n const mine: Open = { close: () => latest.current() };\n for (const other of [...opened]) other.close();\n opened.add(mine);\n return () => { opened.delete(mine); };\n }, [open]);\n}\n\n/** A modal bottom sheet with a title. The scrim, Escape, a swipe down and Back close it. */\nexport function AppSheet({ open, onOpenChange, title, children }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly title: string; readonly children: ReactNode }) {\n const close = () => onOpenChange(false);\n useExclusiveSheet(open, close);\n useBackLayer(open, close, 'sheet');\n return <Drawer open={open} onOpenChange={next => { if (!next) close(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"app-sheet\" className=\"max-h-[85dvh] pb-[env(safe-area-inset-bottom)] transition-[transform,opacity,filter]\">\n <DrawerTitle className=\"px-4 pt-1 pb-2\">{title}</DrawerTitle>\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2\">{children}</div>\n </DrawerContent>\n </Drawer>;\n}\n\n/** A full-width row of a sheet: at least 48px tall (44px is the touch minimum). */\nexport const SHEET_ROW = 'flex min-h-12 w-full items-center gap-3 px-4 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent';\n",
|
|
82
|
+
"type": "registry:component",
|
|
83
|
+
"target": "components/app-shell/sheet.tsx"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"path": "registry/app-shell/compact.ts",
|
|
87
|
+
"content": "// Compact means below 768px: the shadcn sidebar's own breakpoint, so the shell and the sidebar always agree.\nexport { useIsMobile as useIsCompact } from '@/hooks/use-mobile';\n",
|
|
88
|
+
"type": "registry:hook",
|
|
89
|
+
"target": "components/app-shell/compact.ts"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"path": "registry/app-shell/index.ts",
|
|
93
|
+
"content": "// The app shell: layout A (`screen`) with its navigation, header and account menu, and the AgentLayer it mounts.\nexport { AppShell } from './app-shell';\nexport type { AppShellProps } from './app-shell';\nexport { AccountMenu, NavUser } from './account';\nexport type { AccountSpec } from './account';\nexport { AppSheet } from './sheet';\nexport { AppSidebar, TabBar } from './nav';\nexport type { NavItem } from './nav';\nexport { ScreenHeader, useScreenHeader } from './header';\nexport type { ScreenHeaderSpec } from './header';\nexport { useIsCompact } from './compact';\nexport { AgentLayer, AgentTrigger, useAgentLayer } from './agent-layer';\nexport { readAgentContext, useAgentContext } from './agent-context';\n",
|
|
94
|
+
"type": "registry:lib",
|
|
95
|
+
"target": "components/app-shell/index.ts"
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"css": {
|
|
99
|
+
"@layer base": {
|
|
100
|
+
".app-shell :where(button, [role=button])": {
|
|
101
|
+
"appearance": "none",
|
|
102
|
+
"background-color": "transparent",
|
|
103
|
+
"border": "0 solid",
|
|
104
|
+
"font": "inherit",
|
|
105
|
+
"color": "inherit",
|
|
106
|
+
"cursor": "pointer",
|
|
107
|
+
"padding": "0"
|
|
108
|
+
},
|
|
109
|
+
".app-shell :where(h2)": {
|
|
110
|
+
"margin": "0",
|
|
111
|
+
"font": "inherit"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"type": "registry:block"
|
|
116
|
+
}
|
package/r/button.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/feedback-preview.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Feedback preview",
|
|
5
5
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.2.
|
|
7
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
8
8
|
"@earendil-works/pi-ai@~1.1.0",
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
package/r/feedback.json
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
"title": "Feedback",
|
|
5
5
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.2.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
9
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
9
|
+
"@hachej/boring-files@^0.2.4",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
package/r/file-tree.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"radix-ui@^1.8.0",
|
|
11
11
|
"lucide-react@^1.51.0"
|
|
12
12
|
],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/html-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
package/r/markdown-editor.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Markdown editor",
|
|
5
5
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
package/r/pi-ambient.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Pi ambient agent",
|
|
5
5
|
"description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"react-dom@^19.0.0",
|
|
10
10
|
"@earendil-works/pi-durable@~1.1.0",
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-workspace.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
19
19
|
],
|
|
20
20
|
"files": [
|
|
21
21
|
{
|
package/r/pi-app.json
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
"title": "Pi agent app",
|
|
5
5
|
"description": "AgentWorkspace: a whole agent page in one component. Left: the sessions pane (search, New, rename, archive, delete; collapsible, a drawer on a narrow screen). Center: PiChat. Right: the artifact viewers (artifact panel with versions, file viewer, auto-open of new artifacts) in the ArtifactWorkspace panel, a full-screen sheet on a phone. Props are data and callbacks; hooks for the conversations handler (useConversations), the chat transport (useRemoteChat) and saved resources (useSaved).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"lucide-react@^1.51.0",
|
|
12
12
|
"radix-ui@^1.8.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-workspace.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/viewers.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
20
20
|
],
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
package/r/pi-chat.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Pi chat",
|
|
5
5
|
"description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history, a human-in-the-loop question card, an Approve / Deny card for host tools wrapped with requireApproval optional artifact cards that open generated content in the host's panel, and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The optional pi-ambient and pi-workspace blocks build on it.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"use-stick-to-bottom@^1.1.6"
|
|
19
19
|
],
|
|
20
20
|
"registryDependencies": [
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
23
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
23
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
24
24
|
],
|
|
25
25
|
"files": [
|
|
26
26
|
{
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
"path": "registry/pi-chat/use-remote-chat.ts",
|
|
166
|
-
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport { createNativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatIdentity, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createRemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { RemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { PiChatActions } from './pi-chat';\n\nexport type RemoteChatState =\n | { readonly status: 'connecting'; readonly conversationId: string | undefined }\n | { readonly status: 'offline'; readonly conversationId: string | undefined; readonly error: string }\n | { readonly status: 'ready'; readonly conversationId: string; readonly controller: NativeChatController; readonly actions: PiChatActions; readonly configure: RemoteChat['configure'] };\n\n/**\n * One native chat controller per selected conversation over the host's chat transport (`createChatTransportHandler` of\n * `@hachej/boring-agent/chat-transport`). `endpoint(id)` names the transport URL of a conversation; `fetch` is the host's authenticated fetch.\n * The remote chat reopens a dropped stream itself (the controller reads `reconnecting` meanwhile); it never means the task stopped.\n * A failed connect retries every second. The previous conversation's controller is never handed out: it is disposed on change.\n */\nexport function useRemoteChat({ conversationId, endpoint, fetch, identity }: {\n readonly conversationId: string | undefined;\n readonly endpoint: (conversationId: string) => string | URL;\n readonly fetch: (request: Request) => Promise<Response>;\n readonly identity: ChatIdentity | undefined;\n}): RemoteChatState {\n const url = conversationId === undefined ? undefined : String(endpoint(conversationId));\n const key = JSON.stringify([conversationId, url, identity?.runtimeId, identity?.scopeId, identity?.principalId]);\n const binding = useMemo(() => ({ key, fetch }), [key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.fetch = fetch; }, [binding, fetch]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly chat: RemoteChatState }>();\n useEffect(() => {\n if (conversationId === undefined || !identity || url === undefined) return;\n const setChat = (chat: RemoteChatState) => setStored({ binding, chat });\n let disposed = false, controller: NativeChatController | undefined, remote: RemoteChat | undefined;\n setChat({ status: 'connecting', conversationId });\n void (async () => {\n while (!disposed) {\n try {\n remote = await createRemoteChat({ endpoint: url, fetch: request => binding.fetch(request) });\n if (disposed) return void remote.close();\n controller = createNativeChatController({ identity, ...remote });\n await controller.connect();\n if (disposed) return void controller.dispose();\n // Human-in-the-loop answers and queue withdrawal are optional on the transport: pass only what it offers.\n const actions: { answer?: RemoteChat['answer']; withdraw?: RemoteChat['withdraw'] } = {};\n if (typeof remote.answer === 'function') actions.answer = remote.answer;\n if (typeof remote.withdraw === 'function') actions.withdraw = remote.withdraw;\n setChat({ status: 'ready', controller, conversationId, actions, configure: remote.configure });\n return;\n } catch (error) {\n controller?.dispose(); controller = undefined; void remote?.close(); remote = undefined;\n if (disposed) return;\n setChat({ status: 'offline', conversationId, error: String((error as Error)?.message ?? error) });\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n })();\n return () => { disposed = true; controller?.dispose(); void remote?.close(); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return identity && stored?.binding === binding ? stored.chat : { status: 'connecting', conversationId };\n}\n",
|
|
166
|
+
"content": "import { useEffect, useLayoutEffect, useMemo, useState } from 'react';\nimport { createNativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatIdentity, NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createRemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { RemoteChat } from '@hachej/boring-ui-kit/remote-chat';\nimport type { PiChatActions } from './pi-chat';\n\nexport type RemoteChatState =\n | { readonly status: 'connecting'; readonly conversationId: string | undefined }\n | { readonly status: 'offline'; readonly conversationId: string | undefined; readonly error: string }\n | { readonly status: 'ready'; readonly conversationId: string; readonly controller: NativeChatController; readonly actions: PiChatActions; readonly configure: RemoteChat['configure'] };\n\n/**\n * One native chat controller per selected conversation over the host's chat transport (`createChatTransportHandler` of\n * `@hachej/boring-agent/chat-transport`). `endpoint(id)` names the transport URL of a conversation; `fetch` is the host's authenticated fetch.\n * The remote chat reopens a dropped stream itself (the controller reads `reconnecting` meanwhile); it never means the task stopped.\n * A failed connect retries every second. The previous conversation's controller is never handed out: it is disposed on change.\n */\nexport function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext }: {\n readonly conversationId: string | undefined;\n readonly endpoint: (conversationId: string) => string | URL;\n readonly fetch: (request: Request) => Promise<Response>;\n readonly identity: ChatIdentity | undefined;\n /** The current screen context, read at each submit and sent as the submit's `context` (for example `readAgentContext` of the app-shell item). */\n readonly requestContext?: () => Readonly<Record<string, unknown>> | undefined;\n}): RemoteChatState {\n const url = conversationId === undefined ? undefined : String(endpoint(conversationId));\n const key = JSON.stringify([conversationId, url, identity?.runtimeId, identity?.scopeId, identity?.principalId]);\n const binding = useMemo(() => ({ key, fetch, requestContext }), [key]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { binding.fetch = fetch; binding.requestContext = requestContext; }, [binding, fetch, requestContext]);\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly chat: RemoteChatState }>();\n useEffect(() => {\n if (conversationId === undefined || !identity || url === undefined) return;\n const setChat = (chat: RemoteChatState) => setStored({ binding, chat });\n let disposed = false, controller: NativeChatController | undefined, remote: RemoteChat | undefined;\n setChat({ status: 'connecting', conversationId });\n void (async () => {\n while (!disposed) {\n try {\n remote = await createRemoteChat({ endpoint: url, fetch: request => binding.fetch(request), requestContext: () => binding.requestContext?.() });\n if (disposed) return void remote.close();\n controller = createNativeChatController({ identity, ...remote });\n await controller.connect();\n if (disposed) return void controller.dispose();\n // Human-in-the-loop answers and queue withdrawal are optional on the transport: pass only what it offers.\n const actions: { answer?: RemoteChat['answer']; withdraw?: RemoteChat['withdraw'] } = {};\n if (typeof remote.answer === 'function') actions.answer = remote.answer;\n if (typeof remote.withdraw === 'function') actions.withdraw = remote.withdraw;\n setChat({ status: 'ready', controller, conversationId, actions, configure: remote.configure });\n return;\n } catch (error) {\n controller?.dispose(); controller = undefined; void remote?.close(); remote = undefined;\n if (disposed) return;\n setChat({ status: 'offline', conversationId, error: String((error as Error)?.message ?? error) });\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n })();\n return () => { disposed = true; controller?.dispose(); void remote?.close(); };\n }, [binding]); // eslint-disable-line react-hooks/exhaustive-deps\n return identity && stored?.binding === binding ? stored.chat : { status: 'connecting', conversationId };\n}\n",
|
|
167
167
|
"type": "registry:hook",
|
|
168
168
|
"target": "components/pi-chat/use-remote-chat.ts"
|
|
169
169
|
},
|
package/r/pi-workspace.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"react-dom@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/provider-setup.json
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/registry.json
CHANGED
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
"lucide-react@^1.51.0"
|
|
108
108
|
],
|
|
109
109
|
"registryDependencies": [
|
|
110
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
111
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
110
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
111
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
112
112
|
],
|
|
113
113
|
"files": [
|
|
114
114
|
{
|
|
@@ -129,8 +129,8 @@
|
|
|
129
129
|
"title": "Markdown editor",
|
|
130
130
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
131
131
|
"dependencies": [
|
|
132
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
133
|
-
"@hachej/boring-files@^0.2.
|
|
132
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
133
|
+
"@hachej/boring-files@^0.2.4",
|
|
134
134
|
"react@^19.0.0",
|
|
135
135
|
"react-dom@^19.0.0",
|
|
136
136
|
"marked@^17.0.6",
|
|
@@ -343,8 +343,8 @@
|
|
|
343
343
|
"title": "HTML source and passive preview",
|
|
344
344
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
345
345
|
"dependencies": [
|
|
346
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
347
|
-
"@hachej/boring-files@^0.2.
|
|
346
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
347
|
+
"@hachej/boring-files@^0.2.4",
|
|
348
348
|
"react@^19.0.0",
|
|
349
349
|
"react-dom@^19.0.0"
|
|
350
350
|
],
|
|
@@ -435,8 +435,8 @@
|
|
|
435
435
|
"title": "Pi chat",
|
|
436
436
|
"description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history, a human-in-the-loop question card, an Approve / Deny card for host tools wrapped with requireApproval optional artifact cards that open generated content in the host's panel, and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The optional pi-ambient and pi-workspace blocks build on it.",
|
|
437
437
|
"dependencies": [
|
|
438
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
439
|
-
"@hachej/boring-files@^0.2.
|
|
438
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
439
|
+
"@hachej/boring-files@^0.2.4",
|
|
440
440
|
"react@^19.0.0",
|
|
441
441
|
"react-dom@^19.0.0",
|
|
442
442
|
"marked@^17.0.6",
|
|
@@ -449,9 +449,9 @@
|
|
|
449
449
|
"use-stick-to-bottom@^1.1.6"
|
|
450
450
|
],
|
|
451
451
|
"registryDependencies": [
|
|
452
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
453
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
454
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
452
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
453
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
454
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
455
455
|
],
|
|
456
456
|
"files": [
|
|
457
457
|
{
|
|
@@ -671,7 +671,7 @@
|
|
|
671
671
|
"title": "Pi ambient agent",
|
|
672
672
|
"description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
|
|
673
673
|
"dependencies": [
|
|
674
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
674
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
675
675
|
"react@^19.0.0",
|
|
676
676
|
"react-dom@^19.0.0",
|
|
677
677
|
"@earendil-works/pi-durable@~1.1.0",
|
|
@@ -679,10 +679,10 @@
|
|
|
679
679
|
"lucide-react@^1.51.0"
|
|
680
680
|
],
|
|
681
681
|
"registryDependencies": [
|
|
682
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
683
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
684
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
685
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
682
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
683
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-workspace.json",
|
|
684
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
685
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
686
686
|
],
|
|
687
687
|
"files": [
|
|
688
688
|
{
|
|
@@ -712,8 +712,8 @@
|
|
|
712
712
|
"react-dom@^19.0.0"
|
|
713
713
|
],
|
|
714
714
|
"registryDependencies": [
|
|
715
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
716
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
715
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
716
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
717
717
|
],
|
|
718
718
|
"files": [
|
|
719
719
|
{
|
|
@@ -734,19 +734,19 @@
|
|
|
734
734
|
"title": "Pi agent app",
|
|
735
735
|
"description": "AgentWorkspace: a whole agent page in one component. Left: the sessions pane (search, New, rename, archive, delete; collapsible, a drawer on a narrow screen). Center: PiChat. Right: the artifact viewers (artifact panel with versions, file viewer, auto-open of new artifacts) in the ArtifactWorkspace panel, a full-screen sheet on a phone. Props are data and callbacks; hooks for the conversations handler (useConversations), the chat transport (useRemoteChat) and saved resources (useSaved).",
|
|
736
736
|
"dependencies": [
|
|
737
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
738
|
-
"@hachej/boring-files@^0.2.
|
|
737
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
738
|
+
"@hachej/boring-files@^0.2.4",
|
|
739
739
|
"react@^19.0.0",
|
|
740
740
|
"react-dom@^19.0.0",
|
|
741
741
|
"lucide-react@^1.51.0",
|
|
742
742
|
"radix-ui@^1.8.0"
|
|
743
743
|
],
|
|
744
744
|
"registryDependencies": [
|
|
745
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
746
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
747
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
748
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
749
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
745
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
746
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-workspace.json",
|
|
747
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/viewers.json",
|
|
748
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
749
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
750
750
|
],
|
|
751
751
|
"files": [
|
|
752
752
|
{
|
|
@@ -811,14 +811,117 @@
|
|
|
811
811
|
}
|
|
812
812
|
]
|
|
813
813
|
},
|
|
814
|
+
{
|
|
815
|
+
"name": "app-shell",
|
|
816
|
+
"type": "registry:block",
|
|
817
|
+
"title": "App shell: screen layout and agent layer",
|
|
818
|
+
"description": "AppShell layout \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on the shadcn drawer, sidebar, avatar and dropdown-menu of the host's own style (Base UI variants).",
|
|
819
|
+
"dependencies": [
|
|
820
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
821
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
822
|
+
"@base-ui/react@^1.8.0",
|
|
823
|
+
"react@^19.0.0",
|
|
824
|
+
"react-dom@^19.0.0",
|
|
825
|
+
"lucide-react@^1.51.0"
|
|
826
|
+
],
|
|
827
|
+
"registryDependencies": [
|
|
828
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
829
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/feedback.json",
|
|
830
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
831
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json",
|
|
832
|
+
"drawer",
|
|
833
|
+
"sidebar",
|
|
834
|
+
"avatar",
|
|
835
|
+
"dropdown-menu"
|
|
836
|
+
],
|
|
837
|
+
"css": {
|
|
838
|
+
"@layer base": {
|
|
839
|
+
".app-shell :where(button, [role=button])": {
|
|
840
|
+
"appearance": "none",
|
|
841
|
+
"background-color": "transparent",
|
|
842
|
+
"border": "0 solid",
|
|
843
|
+
"font": "inherit",
|
|
844
|
+
"color": "inherit",
|
|
845
|
+
"cursor": "pointer",
|
|
846
|
+
"padding": "0"
|
|
847
|
+
},
|
|
848
|
+
".app-shell :where(h2)": {
|
|
849
|
+
"margin": "0",
|
|
850
|
+
"font": "inherit"
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
"files": [
|
|
855
|
+
{
|
|
856
|
+
"path": "registry/app-shell/agent-layer.tsx",
|
|
857
|
+
"type": "registry:component",
|
|
858
|
+
"target": "components/app-shell/agent-layer.tsx"
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
"path": "registry/app-shell/agent-context.ts",
|
|
862
|
+
"type": "registry:lib",
|
|
863
|
+
"target": "components/app-shell/agent-context.ts"
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
"path": "registry/app-shell/back.ts",
|
|
867
|
+
"type": "registry:lib",
|
|
868
|
+
"target": "components/app-shell/back.ts"
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
"path": "registry/app-shell/use-visual-viewport.ts",
|
|
872
|
+
"type": "registry:hook",
|
|
873
|
+
"target": "components/app-shell/use-visual-viewport.ts"
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
"path": "registry/app-shell/text.ts",
|
|
877
|
+
"type": "registry:lib",
|
|
878
|
+
"target": "components/app-shell/text.ts"
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
"path": "registry/app-shell/app-shell.tsx",
|
|
882
|
+
"type": "registry:component",
|
|
883
|
+
"target": "components/app-shell/app-shell.tsx"
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
"path": "registry/app-shell/header.tsx",
|
|
887
|
+
"type": "registry:component",
|
|
888
|
+
"target": "components/app-shell/header.tsx"
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
"path": "registry/app-shell/nav.tsx",
|
|
892
|
+
"type": "registry:component",
|
|
893
|
+
"target": "components/app-shell/nav.tsx"
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
"path": "registry/app-shell/account.tsx",
|
|
897
|
+
"type": "registry:component",
|
|
898
|
+
"target": "components/app-shell/account.tsx"
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
"path": "registry/app-shell/sheet.tsx",
|
|
902
|
+
"type": "registry:component",
|
|
903
|
+
"target": "components/app-shell/sheet.tsx"
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
"path": "registry/app-shell/compact.ts",
|
|
907
|
+
"type": "registry:hook",
|
|
908
|
+
"target": "components/app-shell/compact.ts"
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
"path": "registry/app-shell/index.ts",
|
|
912
|
+
"type": "registry:lib",
|
|
913
|
+
"target": "components/app-shell/index.ts"
|
|
914
|
+
}
|
|
915
|
+
]
|
|
916
|
+
},
|
|
814
917
|
{
|
|
815
918
|
"name": "viewers",
|
|
816
919
|
"type": "registry:component",
|
|
817
920
|
"title": "Viewers",
|
|
818
921
|
"description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, and a PDF viewer. Owned, editable source over the borrowed controllers; no AI SDK.",
|
|
819
922
|
"dependencies": [
|
|
820
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
821
|
-
"@hachej/boring-files@^0.2.
|
|
923
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
924
|
+
"@hachej/boring-files@^0.2.4",
|
|
822
925
|
"react@^19.0.0",
|
|
823
926
|
"react-dom@^19.0.0",
|
|
824
927
|
"marked@^17.0.6",
|
|
@@ -837,9 +940,9 @@
|
|
|
837
940
|
"lucide-react@^1.51.0"
|
|
838
941
|
],
|
|
839
942
|
"registryDependencies": [
|
|
840
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
841
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
842
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
943
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
944
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
945
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
843
946
|
],
|
|
844
947
|
"files": [
|
|
845
948
|
{
|
|
@@ -1462,8 +1565,8 @@
|
|
|
1462
1565
|
"lucide-react@^1.51.0"
|
|
1463
1566
|
],
|
|
1464
1567
|
"registryDependencies": [
|
|
1465
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
1466
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
1568
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
1569
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
1467
1570
|
],
|
|
1468
1571
|
"files": [
|
|
1469
1572
|
{
|
|
@@ -1479,9 +1582,9 @@
|
|
|
1479
1582
|
"title": "Feedback",
|
|
1480
1583
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
1481
1584
|
"dependencies": [
|
|
1482
|
-
"@hachej/boring-feedback@^0.2.
|
|
1483
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
1484
|
-
"@hachej/boring-files@^0.2.
|
|
1585
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
1586
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
1587
|
+
"@hachej/boring-files@^0.2.4",
|
|
1485
1588
|
"react@^19.0.0",
|
|
1486
1589
|
"react-dom@^19.0.0",
|
|
1487
1590
|
"lucide-react@^1.51.0"
|
|
@@ -2221,8 +2324,8 @@
|
|
|
2221
2324
|
}
|
|
2222
2325
|
},
|
|
2223
2326
|
"registryDependencies": [
|
|
2224
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
2225
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
2327
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
|
|
2328
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
|
|
2226
2329
|
]
|
|
2227
2330
|
},
|
|
2228
2331
|
{
|
|
@@ -2231,7 +2334,7 @@
|
|
|
2231
2334
|
"title": "Feedback preview",
|
|
2232
2335
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
2233
2336
|
"dependencies": [
|
|
2234
|
-
"@hachej/boring-feedback@^0.2.
|
|
2337
|
+
"@hachej/boring-feedback@^0.2.4",
|
|
2235
2338
|
"@earendil-works/pi-ai@~1.1.0",
|
|
2236
2339
|
"react@^19.0.0"
|
|
2237
2340
|
],
|
|
@@ -2249,8 +2352,8 @@
|
|
|
2249
2352
|
"title": "Consumer task-list viewer",
|
|
2250
2353
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
2251
2354
|
"dependencies": [
|
|
2252
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
2253
|
-
"@hachej/boring-files@^0.2.
|
|
2355
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
2356
|
+
"@hachej/boring-files@^0.2.4",
|
|
2254
2357
|
"react@^19.0.0",
|
|
2255
2358
|
"react-dom@^19.0.0",
|
|
2256
2359
|
"zod@^4.6.5"
|
|
@@ -2316,14 +2419,14 @@
|
|
|
2316
2419
|
"title": "File tree",
|
|
2317
2420
|
"description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
|
|
2318
2421
|
"dependencies": [
|
|
2319
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
2320
|
-
"@hachej/boring-files@^0.2.
|
|
2422
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
2423
|
+
"@hachej/boring-files@^0.2.4",
|
|
2321
2424
|
"react@^19.0.0",
|
|
2322
2425
|
"radix-ui@^1.8.0",
|
|
2323
2426
|
"lucide-react@^1.51.0"
|
|
2324
2427
|
],
|
|
2325
2428
|
"registryDependencies": [
|
|
2326
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
2429
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json"
|
|
2327
2430
|
],
|
|
2328
2431
|
"files": [
|
|
2329
2432
|
{
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
package/r/viewers.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Viewers",
|
|
5
5
|
"description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, and a PDF viewer. Owned, editable source over the borrowed controllers; no AI SDK.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.2.
|
|
8
|
-
"@hachej/boring-files@^0.2.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.2.4",
|
|
8
|
+
"@hachej/boring-files@^0.2.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lucide-react@^1.51.0"
|
|
25
25
|
],
|
|
26
26
|
"registryDependencies": [
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
29
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json",
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
|
|
29
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
|
|
30
30
|
],
|
|
31
31
|
"files": [
|
|
32
32
|
{
|