@hachej/boring-ui-kit 0.3.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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.3.0",
113
+ "@hachej/boring-files": "^0.3.1",
114
114
  "@earendil-works/pi-durable": "~1.2.0",
115
115
  "react": "^19.0.0",
116
116
  "react-dom": "^19.0.0",
package/r/app-shell.json CHANGED
@@ -4,18 +4,19 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"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
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.3.0",
8
- "@hachej/boring-ui-kit@^0.3.0",
7
+ "@hachej/boring-feedback@^0.3.1",
8
+ "@hachej/boring-ui-kit@^0.3.1",
9
9
  "@base-ui/react@^1.8.0",
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.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/feedback.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/dock.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/feedback.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json",
19
20
  "drawer",
20
21
  "sidebar",
21
22
  "avatar",
@@ -24,7 +25,7 @@
24
25
  "files": [
25
26
  {
26
27
  "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 { AppShellLocale, 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 /** The shell's `locale`: the layer's own words when the agent spec sets none (`agent.chatProps.locale` first). */\n readonly locale: AppShellLocale | undefined;\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, locale: AppShellLocale | 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, locale });\n latest.current = { agent, done, locale };\n const set = (next: VoicePhase) => { phaseRef.current = next; setPhase(next); };\n const words = () => chatTextFor(latest.current.agent?.chatProps?.locale ?? latest.current.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, locale, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: AgentSpec | undefined; readonly locale?: AppShellLocale | 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, locale, 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, locale,\n }), [state, snap, shown, open, close, expanded, agent, improve, setSnap, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale]);\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 ?? layer.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 const layer = useInternal();\n const locale = spec.chatProps?.locale ?? layer.locale;\n // One annotate toggle: pins on desktop, point mode (which hides the layer while the person taps the app) on touch and compact screens.\n const feedback = useComposerFeedback(spec.feedback);\n const pointing = usePointing(spec.feedback);\n // The pin's mic uses the shell's transcription: this agent's own, else the coach's.\n const transcribe = (spec.voice ?? layer.agent?.voice)?.transcribe;\n const fr = 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} {...(locale ? { locale } : {})} controller={spec.controller} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { tools: <>{spec.chatProps?.tools}<TalkButton text={appShellText(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\">\n <NoteBubble session={spec.feedback} {...(transcribe ? { transcribe } : {})} {...(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 ?? layer.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 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-annotate]]: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 pointed = pointingAgent ? layer.agent : layer.improve;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.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} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n </>}\n </>;\n}\n",
28
+ "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 `Dock` (the `dock` item) beside the app, docked on the left or the right (default right)\n// or floating over it; the person chooses and the choice is remembered. 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, HTMLAttributes, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, Ref, RefObject } from 'react';\nimport { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon, Loader2Icon, MicIcon, PanelLeftIcon, PanelRightIcon, PictureInPicture2Icon, 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 { useIsMobile } from '@/hooks/use-mobile';\nimport { Dock, DockLayout, DockMain } from '../dock/dock';\nimport type { DockApi } from '../dock/dock';\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 { AppShellLocale, AppShellText } from './text';\nimport { useVisualViewport } from './use-visual-viewport';\nimport { AgentContextProvider } from './agent-context';\nimport { HeaderActions } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\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 /** Host actions in the layer's title row (before the dock controls, or the snap and close controls when compact), as the screen header's. Two shown, the rest in « … ». Not at peek. */\n readonly headerActions?: readonly ScreenHeaderAction[];\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, a lost focus and Escape never close the layer: they step down\n * to peek (half without an `agent`), so the modal state goes and the coach stays reachable. Only the Close control closes it. `reason` is\n * Base UI's `details.reason` (`'swipe'`, `'outside-press'`, `'focus-out'`, `'escape-key'`, `'close-watcher'`, `'close-press'`, `'trigger-press'`,\n * `'imperative-action'`, `'none'`).\n */\nexport function nextSnapOnClose(reason: string, hasPeek: boolean): 'peek' | 'half' | 'close' {\n return reason === 'swipe' || reason === 'outside-press' || reason === 'focus-out' || reason === 'escape-key' ? (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 /** The shell's `locale`: the layer's own words when the agent spec sets none (`agent.chatProps.locale` first). */\n readonly locale: AppShellLocale | undefined;\n /** Expanded only: the side the dock sits on while docked (the viewer's choice, remembered). */\n readonly side: DockSide;\n readonly setSide: (side: DockSide) => void;\n /** Set by a dock control: the next placement or side change puts focus back on the panel's first dock control. */\n readonly restoreFocus: { current: boolean };\n}\n\ntype DockSide = 'left' | 'right';\n/** The dock's remembered state lives under this prefix (DockLayout's storageKey): `<key>.agent.floating`, `.width`, and the side. */\nconst DOCK_KEY = 'boring.app-shell';\nconst SIDE_KEY = `${DOCK_KEY}.agent.side`;\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, locale: AppShellLocale | 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, locale });\n latest.current = { agent, done, locale };\n const set = (next: VoicePhase) => { phaseRef.current = next; setPhase(next); };\n const words = () => chatTextFor(latest.current.agent?.chatProps?.locale ?? latest.current.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, locale, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: AgentSpec | undefined; readonly locale?: AppShellLocale | 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 restoreFocus = useRef(false);\n const [side, setSideState] = useState<DockSide>('right');\n // The side is the viewer's, remembered beside the Dock's own placement; read after mount so server and client markup agree.\n useLayoutEffect(() => { try { const saved = globalThis.localStorage?.getItem(SIDE_KEY); if (saved === 'left' || saved === 'right') setSideState(saved); } catch { /* a convenience */ } }, []);\n const setSide = useCallback((next: DockSide) => { setSideState(next); try { globalThis.localStorage?.setItem(SIDE_KEY, next); } catch { /* a convenience */ } }, []);\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, locale, 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, locale, side, setSide, restoreFocus,\n }), [state, snap, shown, open, close, expanded, agent, improve, setSnap, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide]);\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 ?? layer.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 const layer = useInternal();\n const locale = spec.chatProps?.locale ?? layer.locale;\n // One annotate toggle: pins on desktop, point mode (which hides the layer while the person taps the app) on touch and compact screens.\n const feedback = useComposerFeedback(spec.feedback);\n const pointing = usePointing(spec.feedback);\n // The pin's mic uses the shell's transcription: this agent's own, else the coach's.\n const transcribe = (spec.voice ?? layer.agent?.voice)?.transcribe;\n const fr = 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} {...(locale ? { locale } : {})} controller={spec.controller} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { tools: <>{spec.chatProps?.tools}<TalkButton text={appShellText(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\">\n <NoteBubble session={spec.feedback} {...(transcribe ? { transcribe } : {})} {...(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), the dock controls (expanded only) and Close. */\nfunction Header({ text, title, compactSnap, closeRef, controls, className, ...rest }: { readonly text: AppShellText; readonly title: string; readonly compactSnap?: Snap; readonly closeRef?: Ref<HTMLButtonElement>; readonly controls?: ReactNode } & Omit<HTMLAttributes<HTMLDivElement>, 'title'>) {\n const layer = useInternal();\n const back = layer.showing === 'improve' && layer.agent;\n const actions = (layer.showing === 'improve' ? layer.improve : layer.agent)?.headerActions ?? [];\n return <div data-testid=\"agent-layer-header\" {...rest} className={cn('flex shrink-0 items-center gap-1 px-2 pb-1', className)}>\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 {actions.length > 0 && <HeaderActions actions={actions} compact={compactSnap !== undefined} text={text} prefix=\"agent-layer\" run={id => actions.find(action => action.id === id)?.onSelect()} />}\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 {controls}\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 ?? layer.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 // Set while the layer closes; cleared when it opens again (a close is final until the trigger or a choice opens the layer).\n const closing = useRef(false);\n useEffect(() => { if (snap !== 'closed') closing.current = false; }, [snap]);\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 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') { closing.current = true; 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 // Base UI also reports the first snap point (peek) as the drawer closes: that echo must not reopen the layer.\n if (vetoed.current || closing.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-annotate]]: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\n/** The panel inside the dock: the same element docked or floating, so a placement change keeps focus and the chats where they are. */\nfunction DockPanel({ api, side }: { readonly api: DockApi; readonly side: DockSide }) {\n const layer = useInternal();\n const { text, title } = useTitle();\n const open = layer.snap !== 'closed';\n const floating = api.floating;\n const box = useRef<HTMLDivElement>(null);\n const drag = useRef<{ dx: number; dy: number } | null>(null);\n const [pos, setPos] = useState<{ left: number; top: number } | null>(null);\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 // A dock control (or the divider's Alt+Arrow) moves or removes the element that had focus: it goes to the panel's first dock control instead of the page.\n useLayoutEffect(() => {\n const lost = !document.activeElement || document.activeElement === document.body;\n const wanted = layer.restoreFocus.current || (open && lost && api.placement !== 'closed');\n layer.restoreFocus.current = false;\n if (wanted) box.current?.querySelector<HTMLElement>('[data-dock-control]')?.focus({ preventScroll: true });\n }, [api.placement, side, layer.restoreFocus]);\n // A dragged window stays reachable: its remembered position is clamped to the viewport on open, on placement change and on every resize.\n const [viewport, setViewport] = useState(0);\n useEffect(() => { const resize = () => setViewport(count => count + 1); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize); }, []);\n useLayoutEffect(() => {\n const rect = box.current?.getBoundingClientRect();\n if (!pos || !floating || !open || !rect || !rect.width) return;\n const left = Math.min(Math.max(pos.left, 0), Math.max(0, innerWidth - rect.width)), top = Math.min(Math.max(pos.top, 0), Math.max(0, innerHeight - rect.height));\n if (left !== pos.left || top !== pos.top) setPos({ left, top });\n }, [pos, floating, open, viewport]);\n const act = (run: () => void) => () => { layer.restoreFocus.current = true; run(); };\n const control = (id: string, label: string, Icon: ComponentType<{ className?: string }>, run: () => void) =>\n <button type=\"button\" key={id} data-testid={`agent-layer-${id}`} data-dock-control=\"\" aria-label={label} title={label} onClick={act(run)} className={ICON_BUTTON}><Icon className=\"size-4\" aria-hidden=\"true\" /></button>;\n const controls = <>\n {!floating && control('float', text.float, PictureInPicture2Icon, () => api.float())}\n {(floating || side === 'right') && control('dock-left', text.dockLeft, PanelLeftIcon, () => { layer.setSide('left'); api.dock(); })}\n {(floating || side === 'left') && control('dock-right', text.dockRight, PanelRightIcon, () => { layer.setSide('right'); api.dock(); })}\n </>;\n const down = (event: ReactPointerEvent<HTMLDivElement>) => {\n const rect = box.current?.getBoundingClientRect();\n if (!floating || !rect || event.button !== 0 || (event.target as HTMLElement).closest('button, a[href]')) return;\n drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n const move = (event: ReactPointerEvent<HTMLDivElement>) => {\n const grab = drag.current, rect = box.current?.getBoundingClientRect();\n if (!grab || !rect) return;\n setPos({ left: Math.min(Math.max(event.clientX - grab.dx, 0), Math.max(0, innerWidth - rect.width)), top: Math.min(Math.max(event.clientY - grab.dy, 0), Math.max(0, innerHeight - rect.height)) });\n };\n const end = () => { drag.current = null; };\n return <div ref={box} data-testid=\"agent-layer\" data-state={open ? 'open' : 'closed'} data-placement={api.placement} data-side={side} role={floating ? 'dialog' : 'complementary'} aria-label={title}\n style={floating ? pos ?? { right: 16, bottom: 16 } : undefined}\n className={cn('app-shell flex min-h-0 flex-col bg-background text-foreground',\n floating ? 'fixed z-(--app-shell-z-layer) h-[min(36rem,calc(100dvh-2rem))] w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-border shadow-xl'\n : cn('h-full min-w-0 flex-1', side === 'right' ? 'border-l border-border' : 'border-r border-border'))}>\n <Header text={text} title={title} controls={controls} className={cn('pt-2', floating && 'cursor-move touch-none select-none')} onPointerDown={down} onPointerMove={move} onPointerUp={end} onPointerCancel={end} />\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden\"><VoiceProblem /><ChatSlot /></div>\n </div>;\n}\n\nfunction AgentDock({ side }: { readonly side: DockSide }) {\n const layer = useInternal();\n const { title } = useTitle();\n return <Dock id=\"agent\" side={side} floatable defaultWidth={384} minWidth={320} dragBelow={260} open={layer.snap !== 'closed'} onOpenChange={next => { if (!next) layer.close(); }} labels={{ region: title }}>\n {api => <DockPanel api={api} side={side} />}\n </Dock>;\n}\n\n/**\n * Wraps the app's content (placed once by the shell): on a wide screen the agent dock sits on its left or right and the content reflows,\n * or it floats over the content; on a phone this is only the content (the layer is the bottom drawer). Host concern: Pi has no layout.\n */\nexport function AgentDockLayout({ children }: { readonly children: ReactNode }): ReactElement {\n const layer = useInternal();\n const dock = layer.expanded && (layer.agent || layer.improve) ? <AgentDock side={layer.side} /> : null;\n return <DockLayout storageKey={DOCK_KEY} persist=\"local\">\n {layer.side === 'left' && dock}\n <DockMain>{children}</DockMain>\n {layer.side === 'right' && dock}\n </DockLayout>;\n}\n\n/** The layer itself: the drawer on a phone (the wide dock is `AgentDockLayout`). Placed once by the shell, for point mode on both. */\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 pointed = pointingAgent ? layer.agent : layer.improve;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.locale;\n // Point mode: the compact sheet steps aside (the wide dock does not cover the app); the bar sits above the tab bar, outside the layer.\n return <>\n {!layer.expanded && <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} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />\n </>}\n </>;\n}\n",
28
29
  "type": "registry:component",
29
30
  "target": "components/app-shell/agent-layer.tsx"
30
31
  },
@@ -36,7 +37,7 @@
36
37
  },
37
38
  {
38
39
  "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/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof history === 'undefined' ? undefined : (history.state as { appShell?: unknown } | null)?.appShell;\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].toSorted((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 // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n const adopt = persisted === order;\n persisted = undefined;\n if (!adopt) 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
+ "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/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof history === 'undefined' ? undefined : (history.state as { appShell?: unknown } | null)?.appShell;\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].toSorted((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 // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n const adopt = persisted === order;\n persisted = undefined;\n if (!adopt) 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
41
  "type": "registry:lib",
41
42
  "target": "components/app-shell/back.ts"
42
43
  },
@@ -48,13 +49,13 @@
48
49
  },
49
50
  {
50
51
  "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 (the person's name is shown once, in the person block) and the sidebar's name when there is none. */\n readonly account: string;\n /** The header's « … » button. */\n readonly actions: string;\n /** The account menu's theme switch. */\n readonly darkMode: 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 darkMode: 'Dark mode',\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 darkMode: 'Mode sombre',\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
+ "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 (the person's name is shown once, in the person block) and the sidebar's name when there is none. */\n readonly account: string;\n /** The header's « … » button. */\n readonly actions: string;\n /** The account menu's theme switch. */\n readonly darkMode: string;\n /** The label of the tab bar. */\n readonly tabs: string;\n /** The « ← » of a header. */\n readonly goBack: string;\n /** The dock controls of the wide layer: float it over the app, or dock it on a side. */\n readonly float: string;\n readonly dockLeft: string;\n readonly dockRight: 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 darkMode: 'Dark mode',\n tabs: 'Navigation',\n goBack: 'Back',\n float: 'Float over the app',\n dockLeft: 'Dock on the left',\n dockRight: 'Dock on the right',\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 darkMode: 'Mode sombre',\n tabs: 'Navigation',\n goBack: 'Retour',\n float: 'Flotter sur l’app',\n dockLeft: 'Ancrer à gauche',\n dockRight: 'Ancrer à droite',\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
53
  "type": "registry:lib",
53
54
  "target": "components/app-shell/text.ts"
54
55
  },
55
56
  {
56
57
  "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). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`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 { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\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 language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l'assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\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, scrolls }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean }) {\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={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ layout, title, nav, agent, improve, account, route, text }: { readonly layout: AppShellProps['layout']; 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={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\" defaultOpen={layout === 'screen'} 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} scrolls={layout === 'screen'} />\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 const { layout, agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? improve)?.chatProps?.locale ?? props.locale);\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale}>\n <HeaderProvider><Shell layout={layout} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
58
+ "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). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`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 { SidebarProvider } from '@/components/ui/sidebar';\nimport { AccountAvatar, AccountMenu } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, 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 { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\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 language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\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, scrolls }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean }) {\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={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ layout, title, nav, agent, improve, account, route, text }: { readonly layout: AppShellProps['layout']; 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={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\" defaultOpen={layout === 'screen'} className=\"app-shell 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 <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} text={text} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} />\n {compact && <TabBar items={nav} activeId={active.id} onChange={route.onChange} text={text} />}\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n const { layout, agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? improve)?.chatProps?.locale ?? props.locale);\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale}>\n <HeaderProvider><Shell layout={layout} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
58
59
  "type": "registry:component",
59
60
  "target": "components/app-shell/app-shell.tsx"
60
61
  },
@@ -66,10 +67,16 @@
66
67
  },
67
68
  {
68
69
  "path": "registry/app-shell/header.tsx",
69
- "content": "'use client';\n\n// One header per screen. A screen calls `useScreenHeader` and the shell renders it; the most recently mounted caller wins as a whole\n// (a `ListDetail` inside a screen overlays « ← » and a title and keeps the screen's actions) and unmounting restores the previous one. A\n// 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; readonly overlay: boolean }\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/**\n * Registers the calling screen's header while it is mounted and on show. The latest spec is always read at click time. An `overlay`\n * (the shell's own `ListDetail`) is merged over the screen's header whatever the effect order: it adds « ← » and a title, and keeps the actions.\n */\nexport function useScreenHeader(spec: ScreenHeaderSpec, options?: { readonly overlay?: boolean }): void {\n const store = useContext(HeaderContext);\n const active = useContext(ScreenActiveContext);\n const entry = useRef<Entry>({ spec, overlay: options?.overlay === true });\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 // A getter, so a click reads the live specs (a callback that changed without a visible change is still the current one).\n return store?.entries.length ? { get spec() { return mergedSpec(store.entries); } } : undefined;\n}\n\n/** The latest ordinary entry is the whole header; explicit overlays (the shell's `ListDetail`) are merged over it field by field. */\nfunction mergedSpec(entries: readonly Entry[]): ScreenHeaderSpec {\n const base = entries.filter(entry => !entry.overlay).at(-1);\n const spec: Record<string, unknown> = { ...base?.spec };\n for (const entry of entries) if (entry.overlay) for (const [field, value] of Object.entries(entry.spec)) if (value !== undefined) spec[field] = value;\n return spec as ScreenHeaderSpec;\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=\"app-shell 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 {compact && back && avatar}\n <AgentTrigger />\n </header>;\n}\n",
70
+ "content": "'use client';\n\n// One header per screen. A screen calls `useScreenHeader` and the shell renders it; the most recently mounted caller wins as a whole\n// (a `ListDetail` inside a screen overlays « ← » and a title and keeps the screen's actions) and unmounting restores the previous one. A\n// screen the shell has hidden does not count (ScreenActiveContext). Host concern: Pi has no layout.\nimport { createContext, createElement, useContext, useLayoutEffect, useRef, useSyncExternalStore } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { ArrowLeftIcon } from 'lucide-react';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { ScreenActiveContext } from './agent-context';\nimport { HeaderActions, ICON_BUTTON } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\nimport { AgentTrigger } from './agent-layer';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\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\nexport type { ScreenHeaderAction } from './header-actions';\n\ninterface Entry { spec: ScreenHeaderSpec; readonly overlay: boolean }\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/**\n * Registers the calling screen's header while it is mounted and on show. The latest spec is always read at click time. An `overlay`\n * (the shell's own `ListDetail`) is merged over the screen's header whatever the effect order: it adds « ← » and a title, and keeps the actions.\n */\nexport function useScreenHeader(spec: ScreenHeaderSpec, options?: { readonly overlay?: boolean }): void {\n const store = useContext(HeaderContext);\n const active = useContext(ScreenActiveContext);\n const entry = useRef<Entry>({ spec, overlay: options?.overlay === true });\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, action.href, action.target, action.rel, 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 // A getter, so a click reads the live specs (a callback that changed without a visible change is still the current one).\n return store?.entries.length ? { get spec() { return mergedSpec(store.entries); } } : undefined;\n}\n\n/** The latest ordinary entry is the whole header; explicit overlays (the shell's `ListDetail`) are merged over it field by field. */\nfunction mergedSpec(entries: readonly Entry[]): ScreenHeaderSpec {\n const base = entries.filter(entry => !entry.overlay).at(-1);\n const spec: Record<string, unknown> = { ...base?.spec };\n for (const entry of entries) if (entry.overlay) for (const [field, value] of Object.entries(entry.spec)) if (value !== undefined) spec[field] = value;\n return spec as ScreenHeaderSpec;\n}\n\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 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 <HeaderActions actions={actions} compact={compact} text={text} prefix=\"header\" run={run} />\n {compact && back && avatar}\n <AgentTrigger />\n </header>;\n}\n",
70
71
  "type": "registry:component",
71
72
  "target": "components/app-shell/header.tsx"
72
73
  },
74
+ {
75
+ "path": "registry/app-shell/header-actions.tsx",
76
+ "content": "'use client';\n\n// The header actions (`ScreenHeaderAction`) and their renderer, shared by the screen header and the AgentLayer header: up to two icon or label\n// buttons (or links), the rest in « … ». Imports nothing from header.tsx or agent-layer.tsx (the shell's import graph has no cycle).\nimport { useState } from 'react';\nimport type { ComponentType, MouseEvent } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { cn } from '../utils/utils';\nimport { afterBack } from './back';\nimport { AppSheet, SHEET_ROW } from './sheet';\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 /** The action is a link (an anchor); `onSelect` still runs on click. `target` and `rel` are set only when the host passes them. */\n readonly href?: string;\n readonly target?: string;\n readonly rel?: string;\n readonly onSelect: () => void;\n}\n\nexport const 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/** One action as a control: an anchor when it has `href`, else a button. `onSelect` is the caller's run (it reads the live action at click time). */\nfunction ActionControl({ action, prefix, onSelect }: { readonly action: ScreenHeaderAction; readonly prefix: string; readonly onSelect: () => void }) {\n const className = action.showLabel === 'always' ? LABEL_BUTTON : ICON_BUTTON;\n const content = <><action.icon className=\"size-4\" aria-hidden=\"true\" />{action.showLabel === 'always' && action.label}</>;\n return action.href !== undefined\n ? <a href={action.href} target={action.target} rel={action.rel} data-testid={`${prefix}-action-${action.id}`} aria-label={action.label} title={action.label} onClick={onSelect} className={className}>{content}</a>\n : <button type=\"button\" data-testid={`${prefix}-action-${action.id}`} aria-label={action.label} title={action.label} onClick={onSelect} className={className}>{content}</button>;\n}\n\n/** Up to two actions as controls; the rest go in « … » (a sheet when compact, a menu otherwise). `prefix` names the test ids. Shared with the AgentLayer header. */\nexport function HeaderActions({ actions, compact, text, prefix, run }: { readonly actions: readonly ScreenHeaderAction[]; readonly compact: boolean; readonly text: AppShellText; readonly prefix: string; readonly run: (id: string) => void }) {\n const shown = actions.slice(0, 2), extra = actions.slice(2);\n const [sheetOpen, setSheetOpen] = useState(false);\n // A link in the phone sheet: the sheet closes first and a same-tab navigation waits for its history entry (afterBack), so the Back\n // manager never undoes it. A new tab, or a modified click, keeps the browser's own default.\n const sheetLink = (event: MouseEvent<HTMLAnchorElement>, action: ScreenHeaderAction) => {\n setSheetOpen(false);\n if ((action.target !== undefined && action.target !== '_self') || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) { afterBack(() => run(action.id)); return; }\n event.preventDefault();\n const href = action.href ?? '';\n afterBack(() => { run(action.id); window.location.assign(href); });\n };\n return <>\n {shown.map(action => <ActionControl key={action.id} action={action} prefix={prefix} onSelect={() => run(action.id)} />)}\n {extra.length > 0 && (compact\n ? <>\n <button type=\"button\" data-testid={`${prefix}-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 => action.href === undefined\n ? <button key={action.id} type=\"button\" data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={() => { setSheetOpen(false); afterBack(() => run(action.id)); }}><action.icon className=\"size-4\" />{action.label}</button>\n : <a key={action.id} href={action.href} target={action.target} rel={action.rel} data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={event => sheetLink(event, action)}><action.icon className=\"size-4\" />{action.label}</a>)}\n </AppSheet>\n </>\n : <DropdownMenu>\n <DropdownMenuTrigger data-testid={`${prefix}-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=\"app-shell w-48\">\n {extra.map(action => <DropdownMenuItem key={action.id} data-testid={`${prefix}-action-${action.id}`} className=\"pointer-coarse:min-h-11\" onClick={() => run(action.id)}\n render={action.href === undefined ? undefined : <a href={action.href} target={action.target} rel={action.rel} />}><action.icon className=\"size-4\" />{action.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenu>)}\n </>;\n}\n\n",
77
+ "type": "registry:component",
78
+ "target": "components/app-shell/header-actions.tsx"
79
+ },
73
80
  {
74
81
  "path": "registry/app-shell/nav.tsx",
75
82
  "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 tab bar's arrow keys: Left and Right move the focus between the tabs (wrapping). */\nconst 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\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 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",
@@ -78,7 +85,7 @@
78
85
  },
79
86
  {
80
87
  "path": "registry/app-shell/account.tsx",
81
- "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, MoonIcon, 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 { cn } from '../utils/utils';\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 /** The app's resolved theme: one « Mode sombre » switch in the account menu. The host resolves `system` (its preference) to `light` or `dark` before it gets here. Absent: no row. */\n readonly theme?: { readonly value: 'light' | 'dark'; readonly onChange: (value: 'light' | 'dark') => void };\n}\n\n/** The switch's state: on is dark. */\nconst darkOn = (theme: NonNullable<AccountSpec['theme']>) => theme.value === 'dark';\n\nfunction ThemeSwitch({ theme, text }: { readonly theme: NonNullable<AccountSpec['theme']>; readonly text: AppShellText }) {\n const on = darkOn(theme);\n return <button type=\"button\" role=\"switch\" aria-checked={on} data-testid=\"account-theme\" className={SHEET_ROW} onClick={() => theme.onChange(on ? 'light' : 'dark')}>\n <MoonIcon className=\"size-4\" aria-hidden=\"true\" />{text.darkMode}\n <span aria-hidden=\"true\" className={cn('ml-auto flex h-5 w-9 shrink-0 rounded-full p-0.5', on ? 'justify-end bg-primary' : 'justify-start bg-muted')}><span className=\"size-4 rounded-full bg-background\" /></span>\n </button>;\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?.theme && <ThemeSwitch theme={account.theme} text={text} />}\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=\"app-shell 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?.theme && <DropdownMenuItem role=\"switch\" aria-checked={darkOn(account.theme)} data-testid=\"account-theme\" className=\"pointer-coarse:min-h-11\" onClick={() => account.theme?.onChange(darkOn(account.theme!) ? 'light' : 'dark')}><MoonIcon className=\"size-4\" aria-hidden=\"true\" />{text.darkMode}</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",
88
+ "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, MoonIcon, 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 { cn } from '../utils/utils';\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 /** The app's resolved theme: one « Mode sombre » switch in the account menu. The host resolves `system` (its preference) to `light` or `dark` before it gets here. Absent: no row. */\n readonly theme?: { readonly value: 'light' | 'dark'; readonly onChange: (value: 'light' | 'dark') => void };\n}\n\n/** The switch's state: on is dark. */\nconst darkOn = (theme: NonNullable<AccountSpec['theme']>) => theme.value === 'dark';\n\nfunction ThemeSwitch({ theme, text }: { readonly theme: NonNullable<AccountSpec['theme']>; readonly text: AppShellText }) {\n const on = darkOn(theme);\n return <button type=\"button\" role=\"switch\" aria-checked={on} data-testid=\"account-theme\" className={SHEET_ROW} onClick={() => theme.onChange(on ? 'light' : 'dark')}>\n <MoonIcon className=\"size-4\" aria-hidden=\"true\" />{text.darkMode}\n <span aria-hidden=\"true\" className={cn('ml-auto flex h-5 w-9 shrink-0 rounded-full p-0.5', on ? 'justify-end bg-primary' : 'justify-start bg-muted')}><span className=\"size-4 rounded-full bg-background\" /></span>\n </button>;\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?.theme && <ThemeSwitch theme={account.theme} text={text} />}\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=\"app-shell 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?.theme && <DropdownMenuItem role=\"switch\" aria-checked={darkOn(account.theme)} data-testid=\"account-theme\" className=\"pointer-coarse:min-h-11\" onClick={() => account.theme?.onChange(darkOn(account.theme!) ? 'light' : 'dark')}><MoonIcon className=\"size-4\" aria-hidden=\"true\" />{text.darkMode}</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",
82
89
  "type": "registry:component",
83
90
  "target": "components/app-shell/account.tsx"
84
91
  },
@@ -96,7 +103,7 @@
96
103
  },
97
104
  {
98
105
  "path": "registry/app-shell/index.ts",
99
- "content": "// The app shell: layouts A (`screen`), B (`workspace`) and C (`chat`) with their 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 { ListDetail, listDetailView } from './list-detail';\nexport type { ListDetailProps } from './list-detail';\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",
106
+ "content": "// The app shell: layouts A (`screen`), B (`workspace`) and C (`chat`) with their 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 { ListDetail, listDetailView } from './list-detail';\nexport type { ListDetailProps } from './list-detail';\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 { AgentDockLayout, AgentLayer, AgentTrigger, useAgentLayer } from './agent-layer';\nexport { readAgentContext, useAgentContext } from './agent-context';\n",
100
107
  "type": "registry:lib",
101
108
  "target": "components/app-shell/index.ts"
102
109
  }
package/r/button.json CHANGED
@@ -8,8 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
13
12
  ],
14
13
  "files": [
15
14
  {
package/r/dock.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "dock",
4
+ "title": "Docks",
5
+ "description": "Composable docks: DockLayout, Dock, DockMain and useDock lay out any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone. Layout only; what a dock shows is the host's. Installed by pi-workspace (which re-exports it) and app-shell.",
6
+ "dependencies": [
7
+ "react@^19.0.0"
8
+ ],
9
+ "registryDependencies": [
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "registry/dock/dock.tsx",
15
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : String(Math.round(value))); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n return <main data-boring=\"dock-main\" className={cn('relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const floatingChosen = floatable && (record.floating ?? defaultFloating);\n const placement: DockPlacement = !open ? 'closed'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : floatingChosen ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setFloating = useCallback((next: boolean) => { if (floatable) update(id, { floating: next }); }, [floatable, id, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setOpen, toggle: () => setOpen(!open),\n float: () => { setFloating(true); if (!open) setOpen(true); },\n dock: () => { setFloating(false); if (!open) setOpen(true); },\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring/70 group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', docked && contained && '[contain:layout]',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4 backdrop-blur-[2px]\">\n <span className=\"rounded-full border border-border bg-popover px-4 py-2 text-center text-sm font-medium text-popover-foreground shadow-lg\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
16
+ "type": "registry:component",
17
+ "target": "components/dock/dock.tsx"
18
+ }
19
+ ],
20
+ "type": "registry:block"
21
+ }
@@ -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.3.0",
7
+ "@hachej/boring-feedback@^0.3.1",
8
8
  "@earendil-works/pi-ai@~1.2.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.3.0",
8
- "@hachej/boring-ui-kit@^0.3.0",
9
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-feedback@^0.3.1",
8
+ "@hachej/boring-ui-kit@^0.3.1",
9
+ "@hachej/boring-files@^0.3.1",
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.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
@@ -72,7 +72,7 @@
72
72
  },
73
73
  {
74
74
  "path": "registry/feedback/feedback-labels.ts",
75
- "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n /** The pin's one-line desktop hint: holding Alt passes clicks to the app. */\n readonly altHint: string;\n /** The pin's field label, its Add button, and the mic inside the field (record, stop, transcribing, nothing heard, failed). */\n readonly add: string;\n readonly voiceRecord: string;\n readonly voiceStop: string;\n readonly voiceTranscribing: string;\n readonly voiceNothing: string;\n readonly voiceFailed: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n altHint: 'Hold ⌥ to use the app',\n add: 'Add note',\n voiceRecord: 'Speak your note',\n voiceStop: 'Stop recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothing: 'Nothing was heard. Your text is kept.',\n voiceFailed: 'The recording could not be transcribed. Your text is kept.',\n selectionCancelled: 'Selection cancelled.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu'est-ce qui ne va pas ici ?\",\n altHint: 'Maintenez ⌥ pour utiliser l’app',\n add: 'Ajouter la note',\n voiceRecord: 'Dicter votre note',\n voiceStop: 'Arrêter l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothing: 'Rien n’a été entendu. Votre texte est conservé.',\n voiceFailed: 'L’enregistrement n’a pas pu être transcrit. Votre texte est conservé.',\n selectionCancelled: 'Sélection annulée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
75
+ "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n /** The pin's one-line desktop hint: holding Alt passes clicks to the app. */\n readonly altHint: string;\n /** The pin's field label, its Add button, and the mic inside the field (record, stop, transcribing, nothing heard, failed). */\n readonly add: string;\n readonly voiceRecord: string;\n readonly voiceStop: string;\n readonly voiceTranscribing: string;\n readonly voiceNothing: string;\n readonly voiceFailed: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n altHint: 'Hold ⌥ to use the app',\n add: 'Add note',\n voiceRecord: 'Speak your note',\n voiceStop: 'Stop recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothing: 'Nothing was heard. Your text is kept.',\n voiceFailed: 'The recording could not be transcribed. Your text is kept.',\n selectionCancelled: 'Selection cancelled.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu’est-ce qui ne va pas ici ?\",\n altHint: 'Maintenez ⌥ pour utiliser l’app',\n add: 'Ajouter la note',\n voiceRecord: 'Dicter votre note',\n voiceStop: 'Arrêter l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothing: 'Rien n’a été entendu. Votre texte est conservé.',\n voiceFailed: 'L’enregistrement n’a pas pu être transcrit. Votre texte est conservé.',\n selectionCancelled: 'Sélection annulée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
76
76
  "type": "registry:lib",
77
77
  "target": "components/feedback/feedback-labels.ts"
78
78
  },
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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
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.3.0/r/theme.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
@@ -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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
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.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "@earendil-works/pi-durable@~1.2.0",
@@ -12,10 +12,11 @@
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-workspace.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
19
20
  ],
20
21
  "files": [
21
22
  {
package/r/pi-app.json CHANGED
@@ -4,19 +4,20 @@
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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
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.3.0/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/viewers.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-workspace.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/viewers.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
20
21
  ],
21
22
  "files": [
22
23
  {
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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -18,9 +18,8 @@
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.3.0/r/theme.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
24
23
  ],
25
24
  "files": [
26
25
  {
@@ -2,14 +2,16 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "pi-workspace",
4
4
  "title": "Pi workspace layout",
5
- "description": "ArtifactWorkspace: the chat with a right-hand panel that slides in at full height, a resizable divider, full screen, a full-screen sheet on a phone and an opt-in float-when-narrow (the host swaps in AmbientChat from pi-ambient). Layout only; what is open is the host's. Pair it with the viewers item for the panel contents. Also dock.tsx: composable docks (DockLayout, Dock, DockMain, useDock) for any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone.",
5
+ "description": "ArtifactWorkspace: the chat with a right-hand panel that slides in at full height, a resizable divider, full screen, a full-screen sheet on a phone and an opt-in float-when-narrow (the host swaps in AmbientChat from pi-ambient). Layout only; what is open is the host's. Pair it with the viewers item for the panel contents. Also dock.tsx, which re-exports the docks of the dock item (DockLayout, Dock, DockMain, useDock) for any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/dock.json"
13
15
  ],
14
16
  "files": [
15
17
  {
@@ -20,7 +22,7 @@
20
22
  },
21
23
  {
22
24
  "path": "registry/pi-workspace/dock.tsx",
23
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : String(Math.round(value))); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n return <main data-boring=\"dock-main\" className={cn('relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const floatingChosen = floatable && (record.floating ?? defaultFloating);\n const placement: DockPlacement = !open ? 'closed'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : floatingChosen ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setFloating = useCallback((next: boolean) => { if (floatable) update(id, { floating: next }); }, [floatable, id, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setOpen, toggle: () => setOpen(!open),\n float: () => { setFloating(true); if (!open) setOpen(true); },\n dock: () => { setFloating(false); if (!open) setOpen(true); },\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring/70 group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', docked && contained && '[contain:layout]',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4 backdrop-blur-[2px]\">\n <span className=\"rounded-full border border-border bg-popover px-4 py-2 text-center text-sm font-medium text-popover-foreground shadow-lg\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
25
+ "content": "// The docks live in the `dock` item; this re-export keeps `pi-workspace/dock` imports working unchanged.\nexport * from '../dock/dock';\n",
24
26
  "type": "registry:component",
25
27
  "target": "components/pi-workspace/dock.tsx"
26
28
  }
@@ -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.3.0/r/theme.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
14
14
  ],
15
15
  "files": [
16
16
  {
package/r/registry.json CHANGED
@@ -107,8 +107,7 @@
107
107
  "lucide-react@^1.51.0"
108
108
  ],
109
109
  "registryDependencies": [
110
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json",
111
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
110
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
112
111
  ],
113
112
  "files": [
114
113
  {
@@ -129,8 +128,8 @@
129
128
  "title": "Markdown editor",
130
129
  "description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
131
130
  "dependencies": [
132
- "@hachej/boring-ui-kit@^0.3.0",
133
- "@hachej/boring-files@^0.3.0",
131
+ "@hachej/boring-ui-kit@^0.3.1",
132
+ "@hachej/boring-files@^0.3.1",
134
133
  "react@^19.0.0",
135
134
  "react-dom@^19.0.0",
136
135
  "marked@^17.0.6",
@@ -343,8 +342,8 @@
343
342
  "title": "HTML source and passive preview",
344
343
  "description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
345
344
  "dependencies": [
346
- "@hachej/boring-ui-kit@^0.3.0",
347
- "@hachej/boring-files@^0.3.0",
345
+ "@hachej/boring-ui-kit@^0.3.1",
346
+ "@hachej/boring-files@^0.3.1",
348
347
  "react@^19.0.0",
349
348
  "react-dom@^19.0.0"
350
349
  ],
@@ -435,8 +434,8 @@
435
434
  "title": "Pi chat",
436
435
  "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
436
  "dependencies": [
438
- "@hachej/boring-ui-kit@^0.3.0",
439
- "@hachej/boring-files@^0.3.0",
437
+ "@hachej/boring-ui-kit@^0.3.1",
438
+ "@hachej/boring-files@^0.3.1",
440
439
  "react@^19.0.0",
441
440
  "react-dom@^19.0.0",
442
441
  "marked@^17.0.6",
@@ -449,9 +448,8 @@
449
448
  "use-stick-to-bottom@^1.1.6"
450
449
  ],
451
450
  "registryDependencies": [
452
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json",
453
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
454
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
451
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
452
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
455
453
  ],
456
454
  "files": [
457
455
  {
@@ -676,7 +674,7 @@
676
674
  "title": "Pi ambient agent",
677
675
  "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.",
678
676
  "dependencies": [
679
- "@hachej/boring-ui-kit@^0.3.0",
677
+ "@hachej/boring-ui-kit@^0.3.1",
680
678
  "react@^19.0.0",
681
679
  "react-dom@^19.0.0",
682
680
  "@earendil-works/pi-durable@~1.2.0",
@@ -684,10 +682,11 @@
684
682
  "lucide-react@^1.51.0"
685
683
  ],
686
684
  "registryDependencies": [
687
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
688
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
689
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
690
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
685
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
686
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
687
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-workspace.json",
688
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
689
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
691
690
  ],
692
691
  "files": [
693
692
  {
@@ -707,18 +706,39 @@
707
706
  }
708
707
  ]
709
708
  },
709
+ {
710
+ "name": "dock",
711
+ "type": "registry:block",
712
+ "title": "Docks",
713
+ "description": "Composable docks: DockLayout, Dock, DockMain and useDock lay out any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone. Layout only; what a dock shows is the host's. Installed by pi-workspace (which re-exports it) and app-shell.",
714
+ "dependencies": [
715
+ "react@^19.0.0"
716
+ ],
717
+ "registryDependencies": [
718
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
719
+ ],
720
+ "files": [
721
+ {
722
+ "path": "registry/dock/dock.tsx",
723
+ "type": "registry:component",
724
+ "target": "components/dock/dock.tsx"
725
+ }
726
+ ]
727
+ },
710
728
  {
711
729
  "name": "pi-workspace",
712
730
  "type": "registry:block",
713
731
  "title": "Pi workspace layout",
714
- "description": "ArtifactWorkspace: the chat with a right-hand panel that slides in at full height, a resizable divider, full screen, a full-screen sheet on a phone and an opt-in float-when-narrow (the host swaps in AmbientChat from pi-ambient). Layout only; what is open is the host's. Pair it with the viewers item for the panel contents. Also dock.tsx: composable docks (DockLayout, Dock, DockMain, useDock) for any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone.",
732
+ "description": "ArtifactWorkspace: the chat with a right-hand panel that slides in at full height, a resizable divider, full screen, a full-screen sheet on a phone and an opt-in float-when-narrow (the host swaps in AmbientChat from pi-ambient). Layout only; what is open is the host's. Pair it with the viewers item for the panel contents. Also dock.tsx, which re-exports the docks of the dock item (DockLayout, Dock, DockMain, useDock) for any host content on the left or right of the main area, each resizable, floatable, closable and a full-screen sheet on a phone.",
715
733
  "dependencies": [
716
734
  "react@^19.0.0",
717
735
  "react-dom@^19.0.0"
718
736
  ],
719
737
  "registryDependencies": [
720
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
721
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
738
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
739
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
740
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
741
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/dock.json"
722
742
  ],
723
743
  "files": [
724
744
  {
@@ -739,19 +759,20 @@
739
759
  "title": "Pi agent app",
740
760
  "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).",
741
761
  "dependencies": [
742
- "@hachej/boring-ui-kit@^0.3.0",
743
- "@hachej/boring-files@^0.3.0",
762
+ "@hachej/boring-ui-kit@^0.3.1",
763
+ "@hachej/boring-files@^0.3.1",
744
764
  "react@^19.0.0",
745
765
  "react-dom@^19.0.0",
746
766
  "lucide-react@^1.51.0",
747
767
  "radix-ui@^1.8.0"
748
768
  ],
749
769
  "registryDependencies": [
750
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
751
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
752
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/viewers.json",
753
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
754
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
770
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
771
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
772
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-workspace.json",
773
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/viewers.json",
774
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
775
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
755
776
  ],
756
777
  "files": [
757
778
  {
@@ -827,18 +848,19 @@
827
848
  "title": "App shell: layouts, list/detail and agent layer",
828
849
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"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).",
829
850
  "dependencies": [
830
- "@hachej/boring-feedback@^0.3.0",
831
- "@hachej/boring-ui-kit@^0.3.0",
851
+ "@hachej/boring-feedback@^0.3.1",
852
+ "@hachej/boring-ui-kit@^0.3.1",
832
853
  "@base-ui/react@^1.8.0",
833
854
  "react@^19.0.0",
834
855
  "react-dom@^19.0.0",
835
856
  "lucide-react@^1.51.0"
836
857
  ],
837
858
  "registryDependencies": [
838
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
839
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/feedback.json",
840
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
841
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json",
859
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
860
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/dock.json",
861
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/feedback.json",
862
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
863
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json",
842
864
  "drawer",
843
865
  "sidebar",
844
866
  "avatar",
@@ -902,6 +924,11 @@
902
924
  "type": "registry:component",
903
925
  "target": "components/app-shell/header.tsx"
904
926
  },
927
+ {
928
+ "path": "registry/app-shell/header-actions.tsx",
929
+ "type": "registry:component",
930
+ "target": "components/app-shell/header-actions.tsx"
931
+ },
905
932
  {
906
933
  "path": "registry/app-shell/nav.tsx",
907
934
  "type": "registry:component",
@@ -935,8 +962,8 @@
935
962
  "title": "Viewers",
936
963
  "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.",
937
964
  "dependencies": [
938
- "@hachej/boring-ui-kit@^0.3.0",
939
- "@hachej/boring-files@^0.3.0",
965
+ "@hachej/boring-ui-kit@^0.3.1",
966
+ "@hachej/boring-files@^0.3.1",
940
967
  "react@^19.0.0",
941
968
  "react-dom@^19.0.0",
942
969
  "marked@^17.0.6",
@@ -955,9 +982,9 @@
955
982
  "lucide-react@^1.51.0"
956
983
  ],
957
984
  "registryDependencies": [
958
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json",
959
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
960
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
985
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
986
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
987
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
961
988
  ],
962
989
  "files": [
963
990
  {
@@ -1580,8 +1607,8 @@
1580
1607
  "lucide-react@^1.51.0"
1581
1608
  ],
1582
1609
  "registryDependencies": [
1583
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json",
1584
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
1610
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
1611
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
1585
1612
  ],
1586
1613
  "files": [
1587
1614
  {
@@ -1597,9 +1624,9 @@
1597
1624
  "title": "Feedback",
1598
1625
  "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.",
1599
1626
  "dependencies": [
1600
- "@hachej/boring-feedback@^0.3.0",
1601
- "@hachej/boring-ui-kit@^0.3.0",
1602
- "@hachej/boring-files@^0.3.0",
1627
+ "@hachej/boring-feedback@^0.3.1",
1628
+ "@hachej/boring-ui-kit@^0.3.1",
1629
+ "@hachej/boring-files@^0.3.1",
1603
1630
  "react@^19.0.0",
1604
1631
  "react-dom@^19.0.0",
1605
1632
  "lucide-react@^1.51.0"
@@ -2382,8 +2409,8 @@
2382
2409
  }
2383
2410
  },
2384
2411
  "registryDependencies": [
2385
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
2386
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
2412
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/pi-chat.json",
2413
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json"
2387
2414
  ]
2388
2415
  },
2389
2416
  {
@@ -2392,7 +2419,7 @@
2392
2419
  "title": "Feedback preview",
2393
2420
  "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).",
2394
2421
  "dependencies": [
2395
- "@hachej/boring-feedback@^0.3.0",
2422
+ "@hachej/boring-feedback@^0.3.1",
2396
2423
  "@earendil-works/pi-ai@~1.2.0",
2397
2424
  "react@^19.0.0"
2398
2425
  ],
@@ -2410,8 +2437,8 @@
2410
2437
  "title": "Consumer task-list viewer",
2411
2438
  "description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
2412
2439
  "dependencies": [
2413
- "@hachej/boring-ui-kit@^0.3.0",
2414
- "@hachej/boring-files@^0.3.0",
2440
+ "@hachej/boring-ui-kit@^0.3.1",
2441
+ "@hachej/boring-files@^0.3.1",
2415
2442
  "react@^19.0.0",
2416
2443
  "react-dom@^19.0.0",
2417
2444
  "zod@^4.6.5"
@@ -2477,14 +2504,14 @@
2477
2504
  "title": "File tree",
2478
2505
  "description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
2479
2506
  "dependencies": [
2480
- "@hachej/boring-ui-kit@^0.3.0",
2481
- "@hachej/boring-files@^0.3.0",
2507
+ "@hachej/boring-ui-kit@^0.3.1",
2508
+ "@hachej/boring-files@^0.3.1",
2482
2509
  "react@^19.0.0",
2483
2510
  "radix-ui@^1.8.0",
2484
2511
  "lucide-react@^1.51.0"
2485
2512
  ],
2486
2513
  "registryDependencies": [
2487
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json"
2514
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json"
2488
2515
  ],
2489
2516
  "files": [
2490
2517
  {
@@ -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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
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.3.0",
8
- "@hachej/boring-files@^0.3.0",
7
+ "@hachej/boring-ui-kit@^0.3.1",
8
+ "@hachej/boring-files@^0.3.1",
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.3.0/r/theme.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
29
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/theme.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/utils.json",
29
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.1/r/button.json"
30
30
  ],
31
31
  "files": [
32
32
  {