@hachej/boring-ui-kit 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/r/app-shell.json +16 -10
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +56 -0
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +3 -3
- package/r/html-viewer.json +2 -2
- package/r/markdown-editor.json +2 -2
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +10 -10
- package/r/pi-chat.json +11 -11
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/registry.json +109 -54
- package/r/task-list-viewer.json +2 -2
- package/r/viewers.json +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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.
|
|
113
|
+
"@hachej/boring-files": "^0.3.2",
|
|
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,19 +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.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.3.
|
|
9
|
-
"@base-ui/react@^1.
|
|
7
|
+
"@hachej/boring-feedback@^0.3.2",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
9
|
+
"@base-ui/react@^1.9.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.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/dock.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/feedback.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/button.json",
|
|
20
20
|
"drawer",
|
|
21
21
|
"sidebar",
|
|
22
22
|
"avatar",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
{
|
|
27
27
|
"path": "registry/app-shell/agent-layer.tsx",
|
|
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
|
+
"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 { closeShellLayer, registerAgentLayerCloser } from './shell-layer';\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 close = useCallback(() => setSnapState('closed'), []);\n // One full-screen layer at a time: opening the agent or product manager closes a view of the app (`useShellLayer`), and a view closes these.\n useEffect(() => registerAgentLayerCloser(close), [close]);\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 closeShellLayer();\n setShown(target);\n setSnapState(at);\n }, []);\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",
|
|
29
29
|
"type": "registry:component",
|
|
30
30
|
"target": "components/app-shell/agent-layer.tsx"
|
|
31
31
|
},
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
"type": "registry:hook",
|
|
48
48
|
"target": "components/app-shell/use-visual-viewport.ts"
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
"path": "registry/app-shell/shell-layer.ts",
|
|
52
|
+
"content": "'use client';\n\n// A full-screen app view (a document sheet, a pushed view of the app) that takes part in the shell's one-layer-at-a-time rule: opening it\n// closes the agent and product manager layers, and opening either of those closes it. Its Back is the shared Back manager's `sheet` layer\n// (`useBackLayer`), so the browser's Back closes it first. Host concern: Pi has no layers; this is the shell's own presentation rule.\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { useBackLayer } from './back';\n\n/** The shell's agent and product manager layers share one close; the agent layer registers it while mounted. */\nconst agentClosers = new Set<() => void>();\n/** The one view on show, by id. Opening a view replaces the view on show. */\nlet shown: string | undefined;\nconst listeners = new Set<() => void>();\nconst emit = (): void => { for (const listener of [...listeners]) listener(); };\nconst subscribe = (listener: () => void): (() => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };\n\n/** Registers how the agent layer closes (called by `AgentLayerProvider`). Returns the unregister function. */\nexport function registerAgentLayerCloser(close: () => void): () => void {\n agentClosers.add(close);\n return () => { agentClosers.delete(close); };\n}\n\n/** Closes the agent and product manager layers. The agent layer calls it before it opens; a view calls it before it opens. */\nexport function closeAgentLayers(): void {\n for (const close of [...agentClosers]) close();\n}\n\n/** Closes the view on show, if any (the agent layer calls it before it opens, so one layer is open at a time). */\nexport function closeShellLayer(): void {\n if (shown === undefined) return;\n shown = undefined;\n emit();\n}\n\nexport interface ShellLayer {\n readonly open: () => void;\n readonly close: () => void;\n readonly isOpen: boolean;\n}\n\nexport interface ShellLayerOptions {\n /** Asked when the browser's Back would close the view: `false` keeps it open (the history entry is pushed again). */\n readonly confirmBack?: () => boolean;\n}\n\n/** The one owner of each view id: its component registers Back and the history entry. Other callers only observe. */\nconst owners = new Map<string, symbol>();\n\n/**\n * The handle of one full-screen view, `id` being its name in the shell. `open` closes the agent and product manager layers and any other\n * view; `close` closes this one (a no-op when it is not on show). The first component that calls `useShellLayer(id)` owns the view: only\n * it registers the Back layer and the history entry, and only it closes the view when it unmounts. Any other caller (a host's trigger)\n * observes `isOpen` and can open or close, and its own unmount releases nothing but its subscription.\n */\nexport function useShellLayer(id: string, options?: ShellLayerOptions): ShellLayer {\n // The owner is the first component of this id to render (parents render before their children). Claimed while rendering, so a parent\n // that mounts with an observer child is the owner; the claim is released by the owner's own cleanup.\n const [me] = useState(() => {\n const token = Symbol(id);\n const claimed = !owners.has(id);\n if (claimed) owners.set(id, token);\n return { token, owner: claimed };\n });\n const owner = me.owner;\n const isOpen = useSyncExternalStore(subscribe, () => shown === id, () => false);\n const open = useCallback(() => {\n closeAgentLayers();\n if (shown === id) return;\n shown = id;\n emit();\n }, [id]);\n const close = useCallback(() => {\n if (shown !== id) return;\n shown = undefined;\n emit();\n }, [id]);\n useEffect(() => {\n if (!owner) return undefined;\n if (!owners.has(id)) owners.set(id, me.token);\n return () => {\n if (owners.get(id) !== me.token) return;\n // The owner's component is gone: its view goes with it (its history entry is released by the Back layer's cleanup).\n owners.delete(id);\n close();\n };\n }, [id, owner, me, close]);\n const confirmBack = useRef(options?.confirmBack);\n confirmBack.current = options?.confirmBack;\n const back = useCallback(() => {\n // A refused Back keeps the view and its entry: the entry the browser just popped is pushed again.\n if (confirmBack.current && !confirmBack.current()) { history.pushState({ appShell: 'sheet' }, ''); return; }\n close();\n }, [close]);\n // The view is one history entry while it is open, owned by one component only (the Back manager's rules).\n useBackLayer(owner && isOpen, back, 'sheet');\n return { open, close, isOpen };\n}\n",
|
|
53
|
+
"type": "registry:lib",
|
|
54
|
+
"target": "components/app-shell/shell-layer.ts"
|
|
55
|
+
},
|
|
50
56
|
{
|
|
51
57
|
"path": "registry/app-shell/text.ts",
|
|
52
58
|
"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",
|
|
@@ -103,7 +109,7 @@
|
|
|
103
109
|
},
|
|
104
110
|
{
|
|
105
111
|
"path": "registry/app-shell/index.ts",
|
|
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",
|
|
112
|
+
"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 { useShellLayer } from './shell-layer';\nexport type { ShellLayer } from './shell-layer';\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",
|
|
107
113
|
"type": "registry:lib",
|
|
108
114
|
"target": "components/app-shell/index.ts"
|
|
109
115
|
}
|
package/r/button.json
CHANGED
package/r/dock.json
CHANGED
package/r/documents.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "documents",
|
|
4
|
+
"title": "Documents: the Library, a file and its revisions",
|
|
5
|
+
"description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
8
|
+
"@hachej/boring-files@^0.3.2",
|
|
9
|
+
"react@^19.0.0",
|
|
10
|
+
"lucide-react@^1.51.0"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/button.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-app.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/app-shell.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/file-tree.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/viewers.json",
|
|
21
|
+
"drawer"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
{
|
|
25
|
+
"path": "registry/documents/documents.tsx",
|
|
26
|
+
"content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: provider });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <div className=\"flex shrink-0 items-center gap-2 border-b border-border px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <button type=\"button\" data-testid=\"back-to-editing\" onClick={onBack}\n className=\"inline-flex min-h-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11 shrink-0 items-center rounded-lg border border-border bg-background px-3 text-xs pointer-coarse:text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{labels.backToEditing}</button>\n </div>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-y-auto border-t border-border md:max-h-none md:w-72 md:border-t-0 md:border-l\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const tree = useFileTree(provider, version);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0 [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
|
|
27
|
+
"type": "registry:component",
|
|
28
|
+
"target": "components/documents/documents.tsx"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/documents/document-sheet.tsx",
|
|
32
|
+
"content": "'use client';\n\n// One document over the app, for « Voir la fiche »: the host sets `path` from its own handler (for example its `onOpenSpec`) and clears it in\n// `onClose`. The sheet is a full-screen view of the shell (`useShellLayer`): it closes the agent layers when it opens, and Back closes it\n// first. The file is the same viewer the Library opens (editable where the viewer edits). Closing a file with unsaved edits asks first.\n// `onShowAll` adds « Toutes les fiches »: the sheet closes, then the host's navigation runs after the sheet's Back entry is gone.\nimport { useCallback, useEffect, useRef } from 'react';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { afterBack } from '../app-shell/back';\nimport { useShellLayer } from '../app-shell/shell-layer';\nimport { useExclusiveSheet } from '../app-shell/sheet';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport interface DocumentSheetProps {\n readonly provider: RevisionProvider;\n /** The workspace path of the document on show; `undefined` closes the sheet. */\n readonly path: string | undefined;\n /** Called when the sheet closes (its close button, a swipe, Back, « Toutes les fiches »): the host clears `path`. */\n readonly onClose: () => void;\n /** Adds « Toutes les fiches » to the sheet. The sheet closes first, then this runs. */\n readonly onShowAll?: (() => void) | undefined;\n readonly locale?: DocumentsLocale | undefined;\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n readonly className?: string | undefined;\n}\n\n/** The sheet's name in the shell's layers: one sheet of this kind at a time. */\nconst LAYER = 'document';\n\nexport function DocumentSheet({ provider, path, onClose, onShowAll, locale, labels: given, viewers, share, className }: DocumentSheetProps) {\n const shown = path !== undefined;\n const labels = documentsLabels(locale, given);\n // The viewer reports its unsaved edits; a close that would drop them asks first (the Back path too, through the shell layer).\n const dirty = useRef(false);\n const report = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const { open, close, isOpen } = useShellLayer(LAYER, { confirmBack: mayLeave });\n // The host's `path` decides whether the sheet is on show; a close from the sheet itself reports to the host.\n useEffect(() => { if (shown) open(); else close(); }, [shown, open, close]);\n useExclusiveSheet(isOpen, close);\n const wasOpen = useRef(false);\n useEffect(() => {\n if (shown && wasOpen.current && !isOpen) onClose();\n wasOpen.current = isOpen;\n }, [isOpen, shown, onClose]);\n const requestClose = () => { if (mayLeave()) close(); };\n const title = path === undefined ? labels.sheetTitle : path.split('/').pop() || path;\n return <DocumentsWords locale={locale}>\n <Drawer open={isOpen} onOpenChange={next => { if (!next) requestClose(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"document-sheet\" className={cn('app-shell h-[92dvh] max-h-[92dvh] pb-[env(safe-area-inset-bottom)]', className)}>\n {/* The title is for assistive technology: the viewer's own bar shows the file name. */}\n <DrawerTitle className=\"sr-only\">{title}</DrawerTitle>\n {onShowAll && <div className=\"flex shrink-0 justify-end px-3 pt-1 pb-2\">\n <button type=\"button\" data-testid=\"document-sheet-all\" onClick={() => { if (!mayLeave()) return; close(); afterBack(onShowAll); }}\n className=\"inline-flex min-h-8 pointer-coarse:min-h-11 pointer-coarse:min-w-11 shrink-0 items-center rounded-lg border border-border bg-background px-3 text-xs pointer-coarse:text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60\">{labels.allDocuments}</button>\n </div>}\n <div className=\"flex min-h-0 flex-1 flex-col overflow-hidden [&_button]:pointer-coarse:min-h-11 [&_button]:pointer-coarse:min-w-11\">\n {path !== undefined && <FileViewer key={path} path={path} locator={provider.locate(path)} onUnsavedChange={report}\n options={{ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }} onClose={requestClose} />}\n </div>\n </DrawerContent>\n </Drawer>\n </DocumentsWords>;\n}\n",
|
|
33
|
+
"type": "registry:component",
|
|
34
|
+
"target": "components/documents/document-sheet.tsx"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/documents/revisions.tsx",
|
|
38
|
+
"content": "'use client';\n\n// The revisions of one file: what the workspace's history retains for it (`provider.history`, newest first), each with when it was saved and\n// who wrote it (`source`). Every row opens that exact revision read-only; going back to editing is a separate control in the documents\n// screen. The operation id is not shown: the public history carries none (a follow-up for the files seam).\nimport { useEffect, useState } from 'react';\nimport type { RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';\nimport { cn } from '../utils/utils';\nimport type { DocumentsLabels } from './documents-labels';\n\ninterface Loaded { readonly provider: RevisionProvider; readonly path: string; readonly saves: readonly SavedRevision[] }\n\n/**\n * The retained revisions of `path` read from `provider`, read again when `version` changes. The answer is kept with the provider and the\n * path it was read for: another provider or path gets `undefined` at once, and a late answer for one of them is dropped.\n */\nexport function useRevisions(provider: RevisionProvider, path: string, version: number): readonly SavedRevision[] | undefined {\n const [loaded, setLoaded] = useState<Loaded>();\n useEffect(() => {\n const abort = new AbortController();\n const save = (saves: readonly SavedRevision[]) => { if (!abort.signal.aborted) setLoaded({ provider, path, saves }); };\n provider.history(path, abort.signal).then(save, () => save([]));\n return () => abort.abort();\n }, [provider, path, version]);\n return loaded && loaded.provider === provider && loaded.path === path ? loaded.saves : undefined;\n}\n\n/** When a revision was saved, in the page's language; `savedAt` 0 means the journal did not record a time. */\nexport function whenSaved(savedAt: number, locale: string, unknown: string): string {\n if (!savedAt) return unknown;\n return new Date(savedAt).toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** The panel's list: one row per retained revision, newest first. Each row opens its own revision read-only. */\nexport function Revisions({ provider, path, version, selected, locale, labels, onOpen }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n /** Bumped when the workspace's change feed moves, so the list is read again. */\n readonly version: number;\n /** The revision on show in the read-only view, if any. */\n readonly selected: string | undefined;\n readonly locale: string;\n readonly labels: DocumentsLabels;\n readonly onOpen: (revision: string) => void;\n}) {\n const saves = useRevisions(provider, path, version);\n return <section data-testid=\"revisions\" aria-label={labels.revisions} className=\"flex min-h-0 flex-col\">\n <h2 className=\"m-0 px-3 py-2 text-sm font-semibold\">{labels.revisions}</h2>\n {saves === undefined ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.revisionsLoading}</p>\n : saves.length === 0 ? <p role=\"status\" className=\"m-0 px-3 pb-3 text-sm text-muted-foreground\">{labels.noRevisions}</p>\n : <ol className=\"m-0 min-h-0 list-none overflow-y-auto p-1\">\n {saves.map((save, index) => {\n const current = index === 0;\n const on = selected === save.revision;\n const when = whenSaved(save.savedAt, locale, labels.unknownTime);\n const who = save.source ? labels.savedBy[save.source] : labels.savedBy.observed;\n return <li key={save.revision}>\n <button type=\"button\" data-testid=\"revision-item\" data-revision={save.revision} data-current={current ? 'true' : undefined}\n aria-current={on ? 'true' : undefined} aria-label={labels.openRevision(when)} onClick={() => onOpen(save.revision)}\n className={cn('flex min-h-11 w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60',\n on && 'bg-accent')}>\n <span className=\"font-medium\">{current ? labels.latest : when}</span>\n <span className=\"text-xs text-muted-foreground\">{current ? `${when}, ${who}` : who}</span>\n </button>\n </li>;\n })}\n </ol>}\n </section>;\n}\n",
|
|
39
|
+
"type": "registry:component",
|
|
40
|
+
"target": "components/documents/revisions.tsx"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "registry/documents/words.tsx",
|
|
44
|
+
"content": "'use client';\n\n// The words the documents block shows: its own, and the viewers' (the pi-app labels the file viewer and the artifact panel read, and the\n// viewer window's labels the viewer bars read). One locale for all of them: `fr` takes the French sets, anything else English, and a\n// `labels` object overrides any word of the block's own.\nimport type { ReactNode } from 'react';\nimport { defaultAppIcons, defaultAppLabels, AppTextProvider } from '../pi-app/app-labels';\nimport { frenchAppLabels } from '../pi-app/app-labels-fr';\nimport { defaultViewerIcons, defaultViewerLabels, ViewerWindowProvider } from '../viewers/viewer-window';\nimport { frenchViewerLabels } from '../viewers/viewer-labels-fr';\nimport { defaultDocumentsLabels, frenchDocumentsLabels } from './documents-labels';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport type DocumentsLocale = 'en' | 'fr';\n\n/** The block's own words for `locale`, with the host's overrides. */\nexport function documentsLabels(locale: DocumentsLocale | undefined, given: Partial<DocumentsLabels> | undefined): DocumentsLabels {\n return { ...(locale === 'fr' ? frenchDocumentsLabels : defaultDocumentsLabels), ...given };\n}\n\n/** Provides the app's and the viewers' words for `locale` to everything below (the file viewer, the artifact panel, the viewer bars). */\nexport function DocumentsWords({ locale, children }: { readonly locale: DocumentsLocale | undefined; readonly children: ReactNode }) {\n const fr = locale === 'fr';\n return <AppTextProvider value={{ labels: fr ? frenchAppLabels : defaultAppLabels, icons: defaultAppIcons }}>\n <ViewerWindowProvider value={{ labels: fr ? frenchViewerLabels : defaultViewerLabels, icons: defaultViewerIcons }}>{children}</ViewerWindowProvider>\n </AppTextProvider>;\n}\n",
|
|
45
|
+
"type": "registry:component",
|
|
46
|
+
"target": "components/documents/words.tsx"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "registry/documents/documents-labels.ts",
|
|
50
|
+
"content": "/*\n * The words of the documents block as plain data: its own (the Library's title, the revisions panel, the sheet) in English and French. The\n * viewers it composes take their words from the pi-app block (`defaultAppLabels` / `frenchAppLabels`), which `Documents` provides.\n */\n/** Who wrote a revision, as the history reports it. */\nexport type SavedByKind = 'publish' | 'agent' | 'observed';\n\nexport const defaultDocumentsLabels = {\n revisions: 'Revisions',\n noRevisions: 'No saved revisions yet.',\n revisionsLoading: 'Loading revisions…',\n latest: 'Latest',\n openRevision: (when: string) => `Open the version saved ${when}`,\n savedBy: { publish: 'saved', agent: 'written by the agent', observed: 'found in the workspace' } as Record<SavedByKind, string>,\n unknownTime: 'time unknown',\n unsupportedPreview: 'Older versions of this kind have no preview here.',\n backToEditing: 'Back to editing',\n discardChanges: 'This document has unsaved changes. Leave without saving them?',\n readOnlyRevision: 'This version is read-only.',\n revisionLoadFailed: 'This version could not be read.',\n allDocuments: 'All documents',\n sheetTitle: 'Document',\n};\n\nexport type DocumentsLabels = typeof defaultDocumentsLabels;\n\nexport const frenchDocumentsLabels: DocumentsLabels = {\n revisions: 'Versions',\n noRevisions: 'Aucune version enregistrée pour l’instant.',\n revisionsLoading: 'Chargement des versions…',\n latest: 'Dernière',\n openRevision: (when: string) => `Ouvrir la version enregistrée ${when}`,\n savedBy: { publish: 'enregistrée', agent: 'écrite par l’agent', observed: 'trouvée dans l’espace de travail' },\n unknownTime: 'date inconnue',\n unsupportedPreview: 'Les anciennes versions de ce type ne s’affichent pas ici.',\n backToEditing: 'Revenir à la modification',\n discardChanges: 'Cette fiche contient des modifications non enregistrées. Quitter sans les enregistrer ?',\n readOnlyRevision: 'Cette version est en lecture seule.',\n revisionLoadFailed: 'Cette version n’a pas pu être lue.',\n allDocuments: 'Toutes les fiches',\n sheetTitle: 'Fiche',\n};\n",
|
|
51
|
+
"type": "registry:lib",
|
|
52
|
+
"target": "components/documents/documents-labels.ts"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"type": "registry:block"
|
|
56
|
+
}
|
package/r/feedback-preview.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Feedback preview",
|
|
5
5
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.3.
|
|
7
|
+
"@hachej/boring-feedback@^0.3.2",
|
|
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.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.3.
|
|
9
|
-
"@hachej/boring-files@^0.3.
|
|
7
|
+
"@hachej/boring-feedback@^0.3.2",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
9
|
+
"@hachej/boring-files@^0.3.2",
|
|
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.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
package/r/file-tree.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.3.
|
|
8
|
-
"@hachej/boring-files@^0.3.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
8
|
+
"@hachej/boring-files@^0.3.2",
|
|
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.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/html-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.3.
|
|
8
|
-
"@hachej/boring-files@^0.3.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
8
|
+
"@hachej/boring-files@^0.3.2",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
package/r/markdown-editor.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Markdown editor",
|
|
5
5
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.3.
|
|
8
|
-
"@hachej/boring-files@^0.3.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.3.2",
|
|
8
|
+
"@hachej/boring-files@^0.3.2",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|