@hachej/boring-ui-kit 0.4.40 → 0.4.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/task-board.d.ts +2 -0
  2. package/package.json +2 -2
  3. package/r/agent-target.json +4 -4
  4. package/r/app-shell.json +18 -24
  5. package/r/board-viewer.json +6 -6
  6. package/r/button.json +1 -1
  7. package/r/dock.json +1 -1
  8. package/r/documents.json +13 -13
  9. package/r/feedback-preview.json +1 -1
  10. package/r/feedback.json +6 -6
  11. package/r/file-tree.json +4 -4
  12. package/r/highlights.json +3 -3
  13. package/r/html-viewer.json +3 -3
  14. package/r/looks-viewer.json +2 -2
  15. package/r/maquette-viewer.json +1 -1
  16. package/r/markdown-editor.json +3 -3
  17. package/r/mockup-viewer.json +1 -1
  18. package/r/pi-ambient.json +8 -8
  19. package/r/pi-app-kinds.json +10 -10
  20. package/r/pi-app.json +13 -13
  21. package/r/pi-artifacts.json +2 -2
  22. package/r/pi-chat-approval.json +2 -2
  23. package/r/pi-chat-attachments.json +1 -1
  24. package/r/pi-chat-markdown.json +2 -2
  25. package/r/pi-chat-mentions.json +1 -1
  26. package/r/pi-chat-pickers.json +1 -1
  27. package/r/pi-chat-plugins.json +11 -11
  28. package/r/pi-chat-question.json +1 -1
  29. package/r/pi-chat-slash.json +2 -2
  30. package/r/pi-chat-subagent.json +1 -1
  31. package/r/pi-chat-voice.json +3 -3
  32. package/r/pi-chat.json +5 -5
  33. package/r/pi-workspace.json +4 -4
  34. package/r/placed-agent.json +4 -4
  35. package/r/pm-panel.json +42 -28
  36. package/r/provider-setup.json +1 -1
  37. package/r/registry.json +206 -194
  38. package/r/requests-board.json +1 -1
  39. package/r/shared-chat.json +6 -6
  40. package/r/task-board.json +6 -6
  41. package/r/task-list-viewer.json +2 -2
  42. package/r/task-mentions.json +4 -4
  43. package/r/utils.json +6 -0
  44. package/r/version-history.json +2 -2
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +6 -6
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
package/r/app-shell.json CHANGED
@@ -4,27 +4,27 @@
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 explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.40",
8
- "@hachej/boring-ui-kit@^0.4.40",
7
+ "@hachej/boring-feedback@^0.4.42",
8
+ "@hachej/boring-ui-kit@^0.4.42",
9
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.4.40/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pm-panel.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/feedback.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/pm-panel.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/feedback.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/utils.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.42/r/button.json"
23
23
  ],
24
24
  "files": [
25
25
  {
26
26
  "path": "registry/app-shell/agent-layer.tsx",
27
- "content": "'use client';\n\n// The AgentLayer: ONE agent's PiChat above the app. Compact (below 768px): a bottom drawer with three fixed snap points, peek, half and\n// full (shadcn `drawer`, Base UI variant). Expanded: a `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';\n// Behaviour only: Base UI's keyboard-inset provider for the host drawer's content. It renders nothing visible.\nimport * as BaseDrawerParts from '@base-ui/react/drawer';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport type { FeedbackSession } from '@hachej/boring-feedback/ui';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useChatVoice } from '@hachej/boring-ui-kit/chat-react/voice';\nimport type { ChatVoiceHandle as Voice } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, mentionToken, pieces, removeMention } from '@hachej/boring-ui-kit/chat-text';\nimport { Drawer, DrawerContent } from '@/components/ui/drawer';\nimport { usePhone } from '../utils/use-media';\nimport { Dock, DockLayout, DockMain } from '../dock/dock';\nimport { useDraggable } from '../utils/use-draggable';\nimport type { DockApi } from '../dock/dock';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { VoiceMessageConfig } from '../pi-chat/config';\nimport { useRecordingCancellation } from '../pi-chat-voice/use-hold-to-record';\nimport { voiceClock } from '../pi-chat/config';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { PendingVoiceBubble, VoiceMessageButton } from '../pi-chat-voice/voice-message';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { NoteBubble, useComposerFeedback } from '../feedback/feedback-session';\nimport { feedbackLabelsFr } from '../feedback/feedback-labels';\nimport { CommentBanner, useCommenting, usePointing } from '../feedback/point-mode';\nimport { cn } from '../utils/utils';\nimport { useBackLayer } from './back';\nimport { parseAgentLink } from './agent-link';\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 { PmPanelProvider } from '../pm-panel/pm-panel';\nimport { isImproveAgent } from '../pm-panel/pm-panel';\nimport type { ImproveProp } from '../pm-panel/pm-panel';\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 and the header trigger's accessible name (default: the chat's title, then « Assistant »). */\n readonly label?: string;\n /** The agent's icon in the sidebar and the header trigger (default: sparkles). */\n readonly icon?: ComponentType<{ className?: string }>;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Push-to-talk. `message` retains audio plus transcript; `auto` sends text only; `draft` (default) fills the composer. */\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\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 agent: NativeChatController | undefined;\n readonly state: AgentLayerState;\n readonly showing: Which | null;\n open(which?: Which, at?: 'peek' | 'half' | 'full'): void;\n close(): void;\n /** Open the real chat with one card mention. Call directly from a click or swipe release to grant microphone permission. False without an agent. */\n readonly talkAbout: (mention: string, options?: { readonly voice?: boolean; readonly at?: 'half' | 'full' }) => boolean;\n readonly expanded: boolean;\n}\n\n// The three fixed snap points: a peek (the file chip, one composer row and a line of the last reply, `PEEK_REM` above the tab bar), 55% and the full height.\nconst PEEK_REM = 8;\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\n/**\n * Whether a close request comes from something the layer does not own: a full-screen overlay portalled above it (`[data-boring-overlay]`,\n * for example the composer's camera). Its taps count as outside presses and its focus as a focus-out of the drawer, but they must change\n * nothing: the caller cancels the request and leaves the snap alone. Only those three reasons are looked at, and the event target, the\n * related target (focus-out) or the focused element may be the overlay.\n */\nexport function isOverlayEvent(details: { readonly reason: string; readonly event?: Event | undefined }): boolean {\n if (details.reason !== 'outside-press' && details.reason !== 'focus-out' && details.reason !== 'escape-key') return false;\n const inside = (node: unknown) => node instanceof Element && node.closest('[data-boring-overlay]') !== null;\n const event = details.event as (Event & { relatedTarget?: EventTarget | null }) | undefined;\n return inside(event?.target) || inside(event?.relatedTarget) || inside(globalThis.document?.activeElement);\n}\n\ninterface Internal extends Omit<AgentLayerApi, 'agent'> {\n readonly agent: AgentSpec | undefined;\n readonly improve: ImproveProp | undefined;\n readonly snap: Snap;\n readonly setSnap: (snap: Snap) => void;\n /** Whether the wide layout's dock is floating over the app (reported by the panel); with `expanded` it gives the chats' placement. */\n readonly dockFloating: boolean;\n readonly setDockFloating: (floating: boolean) => 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 readonly talkRecording: boolean;\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/** A screen's way to hand the layer an assistant it already owns (see `useAgentLoan`). */\nexport interface AgentLoan {\n /** False when the shell has its own `agent`: a loan never replaces it. */\n readonly available: boolean;\n /** Lends `spec` as the shell's `agent` and opens it at `at` (just lends it without `at`). `undefined` takes it back and closes the layer. */\n lend(spec: AgentSpec | undefined, at?: 'peek' | 'half' | 'full'): void;\n}\ninterface LoanContextValue extends AgentLoan { readonly compact: boolean }\nconst LoanContext = createContext<LoanContextValue | null>(null);\n\n/**\n * For a screen that owns an assistant (a page-sized `AgentWorkspace`): `lend(spec, 'peek')` shows that same controller in the shell's\n * bottom drawer on a phone, so one chat drawer serves the app; `lend(undefined)` returns it. `null` outside an `AppShell`; `compact` says\n * whether the layer is the phone drawer (below 768px), the only place a loan is shown (a wide shell keeps the screen's own placement).\n */\nexport function useAgentLoan(): (AgentLoan & { readonly compact: boolean }) | null { return useContext(LoanContext); }\n\n/** `improve` as the host wrote it: its own spec, or nothing to read for the one line `{ agent }` (the PM panel resolves that). */\nconst hostSpec = (improve: ImproveProp | undefined) => improve && !isImproveAgent(improve) ? improve : undefined;\nconst controllerOf = (spec: AgentSpec | ImproveProp | undefined) => spec && 'controller' in spec ? spec.controller : undefined;\n\n/** The layer's state and controls. */\nexport function useAgentLayer(): AgentLayerApi {\n const { state, showing, open, close, talkAbout, expanded, agent: spec } = useInternal();\n const agent = spec?.controller;\n return useMemo(() => ({ state, showing, open, close, talkAbout, expanded, agent }), [state, showing, open, close, talkAbout, expanded, agent]);\n}\n\n/** Push-to-talk uses the same dictation lifecycle as page targeting, over the existing composer/controller. */\nfunction useVoice(agent: AgentSpec | undefined, locale: AppShellLocale | undefined, done: (outcome: 'sent' | 'drafted') => void): Voice {\n return useChatVoice({ controller: agent?.controller, voice: agent?.voice, attachments: agent?.chatProps?.attachments,\n labels: chatTextFor(agent?.chatProps?.locale ?? locale).labels, onDone: done });\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: own, improve, locale, agentLink, appName, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: ImproveProp | undefined; readonly locale?: AppShellLocale | undefined; readonly agentLink?: false | undefined; readonly appName?: string | undefined; readonly children: ReactNode }): ReactElement {\n const compact = usePhone();\n const expanded = !compact;\n const [snap, setSnapState] = useState<Snap>('closed');\n const [dockFloating, setDockFloating] = useState(false);\n // A screen may lend the layer its assistant (`useAgentLoan`); the shell's own `agent` always wins.\n const [lent, setLent] = useState<AgentSpec | undefined>();\n const agent = own ?? lent;\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage exists only after mount; reading it before paint keeps server and client markup equal without a flash\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, snap, shown });\n // eslint-disable-next-line react-hooks/refs -- latest-value ref: open/lend/showAgent may be called from a child's layout effect, before this component's own effects run\n specs.current = { agent, improve, snap, shown };\n const lentRef = useRef<AgentSpec | undefined>(undefined);\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: 'peek' | '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 === 'peek' && target !== 'agent' ? 'half' : at);\n }, []);\n const lend = useCallback((spec: AgentSpec | undefined, at?: 'peek' | 'half' | 'full') => {\n if (specs.current.agent && specs.current.agent !== lentRef.current) return;\n lentRef.current = spec;\n setLent(spec);\n if (spec && at) { closeShellLayer(); setShown('agent'); setSnapState(at); }\n }, []);\n const loan = useMemo<LoanContextValue>(() => ({ available: !own, lend, compact }), [own, lend, compact]);\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- closes the layer once after its spec goes away; the guard (!shownSpec) settles after one pass\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 // eslint-disable-next-line react-hooks/refs -- a monotonic \"ever opened\" latch for this render: no re-render, no setState during render\n if (snap !== 'closed') opened.current = true;\n\n // Consume the launch URL before an open layer pushes its Back entry. Keep the parsed request through StrictMode's effect replay.\n const launch = useRef<ReturnType<typeof parseAgentLink> | undefined>(undefined);\n const [firstAgentLink] = useState(agentLink);\n useEffect(() => {\n if (launch.current === undefined) {\n launch.current = firstAgentLink === false ? null : parseAgentLink(window.location.search);\n if (firstAgentLink !== false) {\n const url = new URL(window.location.href);\n if (['agent', 'open', 'input'].some(key => url.searchParams.has(key))) {\n for (const key of ['agent', 'open', 'input']) url.searchParams.delete(key);\n history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);\n }\n }\n }\n const link = launch.current;\n if (!link) return;\n const which = link.agent === 'improve' ? 'improve' : 'agent';\n open(which, link.open);\n const spec = specs.current[which];\n if (link.input !== 'text' || !spec) return;\n // The drawer's slide and initial focus finish first. Voice deliberately leaves focus off the composer and never calls start().\n const timer = setTimeout(() => {\n const current = specs.current;\n if (current.snap !== 'closed' && current.shown === which && controllerOf(current[which]) === controllerOf(spec)) {\n host?.querySelector<HTMLElement>(`[data-spec=${which}] [data-testid=composer-input]`)?.focus({ preventScroll: true });\n }\n }, 520);\n return () => clearTimeout(timer);\n }, [host, open, firstAgentLink]);\n\n const voice = useVoice(agent, locale, outcome => {\n if (outcome === 'drafted') { setShown('agent'); setSnapState('half'); }\n });\n const [talkRecording, setTalkRecording] = useState(false);\n const cancelTalkRecording = useCallback(() => {\n if (talkRecording && (voice.phase === 'starting' || voice.phase === 'recording')) voice.cancel();\n }, [talkRecording, voice]);\n useRecordingCancellation(cancelTalkRecording);\n const previousTalk = useRef<{ controller: NativeChatController; path: string } | undefined>(undefined);\n const focusTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(focusTimer.current), [agent?.controller]);\n const talkAbout = useCallback<AgentLayerApi['talkAbout']>((token, options = {}) => {\n const selected = specs.current.agent;\n if (!selected) return false;\n clearTimeout(focusTimer.current);\n const parsed = pieces(token, { mentions: true, skills: [] });\n if (parsed.length !== 1 || parsed[0]?.kind !== 'mention') throw new TypeError('talkAbout requires one @mention token');\n const path = parsed[0].value!;\n const before = previousTalk.current;\n const draft = selected.controller.getSnapshot().draft.text;\n const text = before?.controller === selected.controller ? removeMention(draft, before.path) : draft;\n selected.controller.setText(`${appendToDraft(text, mentionToken(path))} `);\n previousTalk.current = { controller: selected.controller, path };\n open('agent', options.at ?? 'half');\n if (options.voice && selected.voice) {\n setTalkRecording(true);\n // No await or effect: permission belongs to the caller's click or swipe release, even before the chat mounts.\n voice.start();\n } else {\n focusTimer.current = setTimeout(() => {\n if (specs.current.agent?.controller === selected.controller && specs.current.snap !== 'closed' && specs.current.shown === 'agent') host?.querySelector<HTMLElement>('[data-spec=agent] [data-testid=composer-input]')?.focus({ preventScroll: true });\n }, 520);\n }\n return true;\n }, [open, voice, host]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a completed or refused capture resets this gesture's presentation once\n useEffect(() => { if (voice.phase === 'idle') setTalkRecording(false); }, [voice.phase]);\n const revealedFailures = useRef(new Set<number>());\n const voiceSend = agent?.voice?.send, pendingKey = voice.pending?.map(item => `${item.id}:${item.phase}`).join(',');\n useEffect(() => {\n const failure = voice.pending?.findLast(item => item.phase === 'transcribe-failed' || item.phase === 'upload-failed' || item.phase === 'blocked' || item.phase === 'unconfirmed');\n const captureProblem = voiceSend === 'message' && voice.problem;\n const failureId = captureProblem ? voice.captureSequence : failure?.id;\n if (failureId === undefined) { revealedFailures.current.clear(); return; }\n if (revealedFailures.current.has(failureId)) return;\n revealedFailures.current.add(failureId);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reveals a new voice failure once per failure id (the ref guards repeats), so it settles after one pass\n if (!expanded && shown === 'agent' && snap === 'peek') setSnapState('half');\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when a pending item's id or phase changes (pendingKey), not on every new pending array\n }, [expanded, shown, snap, voiceSend, voice.captureSequence, voice.problem, pendingKey]);\n\n // « Améliorer l'app » owns Back like any layer: one entry while the panel shows (sheet or strip), one more for the full panel (C7).\n const improveOpen = snap !== 'closed' && shown === 'improve';\n const [pmFull, setPmFull] = useState<{ readonly full: boolean; readonly collapse: () => void }>({ full: false, collapse: () => {} });\n const onFull = useCallback((full: boolean, collapse: () => void) => setPmFull(current => current.full === full ? current : { full, collapse }), []);\n useBackLayer(improveOpen, close, 'agent');\n useBackLayer(improveOpen && pmFull.full, pmFull.collapse, 'sheet');\n const state: AgentLayerState = snap === 'closed' ? 'closed' : expanded ? 'open' : snap;\n const value = useMemo<Internal>(() => ({\n state, showing: snap === 'closed' ? null : shown, open, close, talkAbout, talkRecording, expanded, agent, improve, snap, setSnap, dockFloating, setDockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide, restoreFocus,\n }), [state, snap, shown, open, close, talkAbout, talkRecording, expanded, agent, improve, setSnap, dockFloating, 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}><LoanContext.Provider value={loan}><PmPanelProvider spec={improve} open={improveOpen} close={close} appName={appName} locale={locale} onFull={onFull}>\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 {/* eslint-disable-next-line react-hooks/refs -- reads the \"ever opened\" latch set above in this same render */}\n {host && opened.current && createPortal(<Chats showing={shown} />, host)}\n </div>\n </PmPanelProvider></LoanContext.Provider></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 const voiceRef = useRef(voice);\n useLayoutEffect(() => { voiceRef.current = voice; });\n const cancelHold = useCallback(() => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (current.held) voiceRef.current.cancel();\n }, []);\n useRecordingCancellation(cancelHold);\n const controller = agent?.controller;\n useEffect(() => () => cancelHold(), [controller, cancelHold]);\n const { triggerUntil: triggerUntilRef } = layer;\n if (!agent) return null;\n\n const toggle = () => {\n triggerUntilRef.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 const name = agent.label ?? text.open;\n const Icon = agent.icon;\n return <Button variant=\"ghost\" size=\"icon\" data-agent-trigger=\"\" data-testid=\"agent-trigger\" aria-label={name} title={name} 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('touch-none', className)}>\n {children ?? (Icon ? <Icon className=\"size-5\" /> : <SparklesIcon data-icon=\"inline-start\" aria-hidden=\"true\" />)}\n </Button>;\n}\n\n/** Tap to start; while active, this same control is the composer's replacement recording row. */\nfunction TalkButton() {\n const { voice } = useInternal();\n const busy = voice.phase === 'transcribing';\n return <VoiceMessageButton testId=\"agent-talk\" disabled={busy || (voice.phase === 'idle' && voice.canStart === false)} voice={{\n ...voice, phase: busy ? 'processing' : voice.phase, canStart: voice.canStart !== false,\n pending: voice.pending ?? [], captureSequence: voice.captureSequence ?? 0,\n }} />;\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 {...withChatPlugins(spec.chatProps ?? {})} {...(locale ? { locale } : {})} controller={spec.controller} keyboardInset={false} placement={layer.expanded && !layer.dockFloating ? 'docked' : 'floating'} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { voiceMessage: { transcribe: spec.voice.transcribe, Bubble: PendingVoiceBubble }, tools: <>{spec.chatProps?.tools}<TalkButton /></>, composerRecording: layer.voice.phase === 'starting' || layer.voice.phase === 'recording' ? <TalkButton /> : undefined, pendingVoice: layer.voice.pending } : {})} />\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 } = useInternal();\n const seen = useRef(new Set<Which>());\n // eslint-disable-next-line react-hooks/refs -- a monotonic \"ever shown\" set filled during render: no setState, no re-render\n seen.current.add(showing);\n // eslint-disable-next-line react-hooks/refs -- reads the \"ever shown\" set filled above in this same render\n const seenAgent = seen.current.has('agent');\n return <>\n {agent && seenAgent && <SpecChat spec={agent} visible={showing === 'agent'} role=\"agent\" />}\n </>;\n}\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.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 variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-back\" aria-label={text.back} title={text.back} onClick={layer.showAgent}><ArrowLeftIcon data-icon=\"inline-start\" 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 variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-expand\" aria-label={text.expand} title={text.expand} onClick={() => layer.setSnap('full')}><ChevronUpIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {compactSnap === 'full' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-collapse\" aria-label={text.collapse} title={text.collapse} onClick={() => layer.setSnap('half')}><ChevronDownIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n <Button variant=\"ghost\" size=\"icon\" ref={closeRef} data-testid=\"agent-layer-close\" aria-label={text.close} title={text.close} onClick={layer.close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nfunction useTitle(): { text: AppShellText; title: string } {\n const layer = useInternal();\n const spec = layer.showing === 'improve' ? hostSpec(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 liveViewport = 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' && layer.showing !== 'improve' && !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 // While the person talks the sheet holds still: the viewport height and the keyboard inset are pinned at what they were when the recording\n // began, the composer takes no focus and no keyboard, so nothing moves under the finger on the microphone.\n const sheet = useRef<HTMLDivElement>(null);\n const idleViewport = useRef(liveViewport);\n // eslint-disable-next-line react-hooks/refs -- ref cache: the last viewport seen while not recording, pinned during a recording; no setState during render\n if (!recording) idleViewport.current = liveViewport;\n // eslint-disable-next-line react-hooks/refs -- reads the pinned viewport cached above in this same render\n const vv = recording ? idleViewport.current : liveViewport;\n const [pinnedBottom, setPinnedBottom] = useState<string | undefined>();\n useLayoutEffect(() => {\n const popup = sheet.current?.closest('[data-slot=drawer-popup]');\n if (!recording || !sheet.current) return;\n if (popup) setPinnedBottom(getComputedStyle(popup).bottom);\n const inputs = [...sheet.current.querySelectorAll<HTMLTextAreaElement>('[data-testid=composer-input]')].map(input => ({ input, mode: input.getAttribute('inputmode'), readOnly: input.readOnly }));\n for (const { input } of inputs) { input.readOnly = true; input.setAttribute('inputmode', 'none'); }\n return () => {\n setPinnedBottom(undefined);\n for (const { input, mode, readOnly } of inputs) { input.readOnly = readOnly; if (mode === null) input.removeAttribute('inputmode'); else input.setAttribute('inputmode', mode); }\n };\n }, [recording]);\n\n // While « Améliorer l'app » shows, the PM panel owns the layer's Back entry (AgentLayerProvider).\n useBackLayer((snap === 'half' || snap === 'full') && layer.showing !== 'improve', 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-peek-height': `${PEEK_REM}rem`, '--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)',\n ...(pinnedBottom ? { bottom: pinnedBottom } : {}) } 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 if (isOverlayEvent(details)) { details.cancel(); 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%_-_var(--app-shell-peek-height))_0)]')}>\n <BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n <div ref={sheet} data-testid=\"agent-layer-sheet\" onFocusCapture={event => { if (!recording && (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]_[data-slot=input-group]]:flex-row! [&_[data-testid=composer]_[data-slot=input-group]]:items-center',\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 voice.phase !== 'starting' && voice.phase !== 'recording' && '[&_[data-testid=composer-controls]]:w-auto [&_[data-testid=composer-controls]]:shrink-0 [&_[data-testid=composer-controls]]:py-0 [&_[data-testid=composer-controls]>span.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 aria-hidden=\"true\" data-testid=\"agent-listening\" data-phase={voice.phase} className=\"sr-only\">\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\" />{voiceClock(voice.seconds)}</> : `${text.listening} ${voiceClock(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 </BaseDrawerParts.Drawer.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' && layer.showing !== 'improve';\n const floating = api.floating;\n // The chats report this as their placement: the phone drawer and the floating panel are `floating`, the dock is `docked`.\n const { setDockFloating } = layer;\n useLayoutEffect(() => { setDockFloating(floating); return () => setDockFloating(false); }, [floating, setDockFloating]);\n const box = useRef<HTMLDivElement>(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 const { restoreFocus: restoreFocusRef } = layer;\n const openRef = useRef(open);\n useLayoutEffect(() => { openRef.current = open; });\n useLayoutEffect(() => {\n const lost = !document.activeElement || document.activeElement === document.body;\n const wanted = restoreFocusRef.current || (openRef.current && lost && api.placement !== 'closed');\n restoreFocusRef.current = false;\n if (wanted) box.current?.querySelector<HTMLElement>('[data-dock-control]')?.focus({ preventScroll: true });\n }, [api.placement, side, restoreFocusRef]);\n const drag = useDraggable({ box, position: open ? pos : null, onPositionChange: setPos, enabled: floating });\n const act = (run: () => void) => () => { restoreFocusRef.current = true; run(); };\n const control = (id: string, label: string, Icon: ComponentType<{ className?: string }>, run: () => void) =>\n <Button variant=\"ghost\" size=\"icon\" key={id} data-testid={`agent-layer-${id}`} data-dock-control=\"\" aria-label={label} title={label} onClick={act(run)}><Icon data-icon=\"inline-start\" 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 // Floating, the host's Card is drawn behind the layer (a sibling layer, so docking or floating never remounts the chat).\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 relative flex min-h-0 flex-col',\n floating ? 'fixed isolate z-(--app-shell-z-layer) h-[min(36rem,calc(100dvh-2rem))] w-[min(24rem,calc(100vw-2rem))]'\n : 'h-full min-w-0 flex-1 bg-background text-foreground')}>\n {floating && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {!floating && <Separator orientation=\"vertical\" className={cn('absolute inset-y-0', side === 'right' ? 'left-0' : 'right-0')} />}\n <Header text={text} title={title} controls={controls} className={cn('pt-2', floating && 'cursor-move touch-none select-none')} {...drag} />\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' && layer.showing !== 'improve'} 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);\n const session = pointingAgent ? layer.agent?.feedback : undefined;\n const commentingAgent = useCommenting(layer.agent?.feedback);\n const commenting = commentingAgent ? layer.agent?.feedback : undefined;\n // Back ends pointing first (the `point` priority), like Done: the notes wait for the next message. The layer keeps its own history entry underneath.\n useBackLayer(session !== undefined, () => { void session?.done(); }, 'point');\n if (!layer.agent && !layer.improve) return null;\n const pointed = layer.agent;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.locale;\n // Comment mode shows its banner either way: on a phone (point mode) above the tab bar, outside the layer, while the compact sheet steps\n // aside; on a wide screen at the top, the dock beside the app. The note bubble is here only while pointing (else inside the layer).\n return <>\n {!layer.expanded && <CompactSheet pointing={session !== undefined} />}\n {commenting && <CommentBanner session={commenting} bottomOffset={`${layer.tabbar}px`} className=\"z-[60]\" {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />}\n {session && <NoteBubble session={session} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />}\n </>;\n}\n",
27
+ "content": "'use client';\n\n// The AgentLayer: ONE agent's PiChat above the app. Compact (below 768px): a bottom drawer with three fixed snap points, peek, half and\n// full (shadcn `drawer`, Base UI variant). Expanded: a `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';\n// Behaviour only: Base UI's keyboard-inset provider for the host drawer's content. It renders nothing visible.\nimport * as BaseDrawerParts from '@base-ui/react/drawer';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport type { FeedbackSession } from '@hachej/boring-feedback/ui';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useChatVoice } from '@hachej/boring-ui-kit/chat-react/voice';\nimport type { ChatVoiceHandle as Voice } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, mentionToken, pieces, removeMention } from '@hachej/boring-ui-kit/chat-text';\nimport { Drawer, DrawerContent } from '@/components/ui/drawer';\nimport { usePhone } from '../utils/use-media';\nimport { Dock, DockLayout, DockMain } from '../dock/dock';\nimport { useDraggable } from '../utils/use-draggable';\nimport type { DockApi } from '../dock/dock';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { VoiceMessageConfig } from '../pi-chat/config';\nimport { useRecordingCancellation } from '../pi-chat-voice/use-hold-to-record';\nimport { voiceClock } from '../pi-chat/config';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { PendingVoiceBubble, VoiceMessageButton } from '../pi-chat-voice/voice-message';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { NoteBubble, useComposerFeedback } from '../feedback/feedback-session';\nimport { feedbackLabelsFr } from '../feedback/feedback-labels';\nimport { CommentBanner, useCommenting, usePointing } from '../feedback/point-mode';\nimport { cn } from '../utils/utils';\nimport { useBackLayer } from '../utils/back';\nimport { parseAgentLink } from './agent-link';\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 { PmPanelProvider } from '../pm-panel/pm-panel';\nimport { isImproveAgent } from '../pm-panel/pm-panel';\nimport type { ImproveProp } from '../pm-panel/pm-panel';\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 and the header trigger's accessible name (default: the chat's title, then « Assistant »). */\n readonly label?: string;\n /** The agent's icon in the sidebar and the header trigger (default: sparkles). */\n readonly icon?: ComponentType<{ className?: string }>;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n /** Push-to-talk. `message` retains audio plus transcript; `auto` sends text only; `draft` (default) fills the composer. */\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\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 agent: NativeChatController | undefined;\n readonly state: AgentLayerState;\n readonly showing: Which | null;\n open(which?: Which, at?: 'peek' | 'half' | 'full'): void;\n close(): void;\n /** Open the real chat with one card mention. Call directly from a click or swipe release to grant microphone permission. False without an agent. */\n readonly talkAbout: (mention: string, options?: { readonly voice?: boolean; readonly at?: 'half' | 'full' }) => boolean;\n readonly expanded: boolean;\n}\n\n// The three fixed snap points: a peek (the file chip, one composer row and a line of the last reply, `PEEK_REM` above the tab bar), 55% and the full height.\nconst PEEK_REM = 8;\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\n/**\n * Whether a close request comes from something the layer does not own: a full-screen overlay portalled above it (`[data-boring-overlay]`,\n * for example the composer's camera). Its taps count as outside presses and its focus as a focus-out of the drawer, but they must change\n * nothing: the caller cancels the request and leaves the snap alone. Only those three reasons are looked at, and the event target, the\n * related target (focus-out) or the focused element may be the overlay.\n */\nexport function isOverlayEvent(details: { readonly reason: string; readonly event?: Event | undefined }): boolean {\n if (details.reason !== 'outside-press' && details.reason !== 'focus-out' && details.reason !== 'escape-key') return false;\n const inside = (node: unknown) => node instanceof Element && node.closest('[data-boring-overlay]') !== null;\n const event = details.event as (Event & { relatedTarget?: EventTarget | null }) | undefined;\n return inside(event?.target) || inside(event?.relatedTarget) || inside(globalThis.document?.activeElement);\n}\n\ninterface Internal extends Omit<AgentLayerApi, 'agent'> {\n readonly agent: AgentSpec | undefined;\n readonly improve: ImproveProp | undefined;\n readonly snap: Snap;\n readonly setSnap: (snap: Snap) => void;\n /** Whether the wide layout's dock is floating over the app (reported by the panel); with `expanded` it gives the chats' placement. */\n readonly dockFloating: boolean;\n readonly setDockFloating: (floating: boolean) => 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 readonly talkRecording: boolean;\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/** A screen's way to hand the layer an assistant it already owns (see `useAgentLoan`). */\nexport interface AgentLoan {\n /** False when the shell has its own `agent`: a loan never replaces it. */\n readonly available: boolean;\n /** Lends `spec` as the shell's `agent` and opens it at `at` (just lends it without `at`). `undefined` takes it back and closes the layer. */\n lend(spec: AgentSpec | undefined, at?: 'peek' | 'half' | 'full'): void;\n}\ninterface LoanContextValue extends AgentLoan { readonly compact: boolean }\nconst LoanContext = createContext<LoanContextValue | null>(null);\n\n/**\n * For a screen that owns an assistant (a page-sized `AgentWorkspace`): `lend(spec, 'peek')` shows that same controller in the shell's\n * bottom drawer on a phone, so one chat drawer serves the app; `lend(undefined)` returns it. `null` outside an `AppShell`; `compact` says\n * whether the layer is the phone drawer (below 768px), the only place a loan is shown (a wide shell keeps the screen's own placement).\n */\nexport function useAgentLoan(): (AgentLoan & { readonly compact: boolean }) | null { return useContext(LoanContext); }\n\n/** `improve` as the host wrote it: its own spec, or nothing to read for the one line `{ agent }` (the PM panel resolves that). */\nconst hostSpec = (improve: ImproveProp | undefined) => improve && !isImproveAgent(improve) ? improve : undefined;\nconst controllerOf = (spec: AgentSpec | ImproveProp | undefined) => spec && 'controller' in spec ? spec.controller : undefined;\n\n/** The layer's state and controls. */\nexport function useAgentLayer(): AgentLayerApi {\n const { state, showing, open, close, talkAbout, expanded, agent: spec } = useInternal();\n const agent = spec?.controller;\n return useMemo(() => ({ state, showing, open, close, talkAbout, expanded, agent }), [state, showing, open, close, talkAbout, expanded, agent]);\n}\n\n/** Push-to-talk uses the same dictation lifecycle as page targeting, over the existing composer/controller. */\nfunction useVoice(agent: AgentSpec | undefined, locale: AppShellLocale | undefined, done: (outcome: 'sent' | 'drafted') => void): Voice {\n return useChatVoice({ controller: agent?.controller, voice: agent?.voice, attachments: agent?.chatProps?.attachments,\n labels: chatTextFor(agent?.chatProps?.locale ?? locale).labels, onDone: done });\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: own, improve, locale, agentLink, appName, children }: { readonly agent?: AgentSpec | undefined; readonly improve?: ImproveProp | undefined; readonly locale?: AppShellLocale | undefined; readonly agentLink?: false | undefined; readonly appName?: string | undefined; readonly children: ReactNode }): ReactElement {\n const compact = usePhone();\n const expanded = !compact;\n const [snap, setSnapState] = useState<Snap>('closed');\n const [dockFloating, setDockFloating] = useState(false);\n // A screen may lend the layer its assistant (`useAgentLoan`); the shell's own `agent` always wins.\n const [lent, setLent] = useState<AgentSpec | undefined>();\n const agent = own ?? lent;\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage exists only after mount; reading it before paint keeps server and client markup equal without a flash\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, snap, shown });\n // eslint-disable-next-line react-hooks/refs -- latest-value ref: open/lend/showAgent may be called from a child's layout effect, before this component's own effects run\n specs.current = { agent, improve, snap, shown };\n const lentRef = useRef<AgentSpec | undefined>(undefined);\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: 'peek' | '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 === 'peek' && target !== 'agent' ? 'half' : at);\n }, []);\n const lend = useCallback((spec: AgentSpec | undefined, at?: 'peek' | 'half' | 'full') => {\n if (specs.current.agent && specs.current.agent !== lentRef.current) return;\n lentRef.current = spec;\n setLent(spec);\n if (spec && at) { closeShellLayer(); setShown('agent'); setSnapState(at); }\n }, []);\n const loan = useMemo<LoanContextValue>(() => ({ available: !own, lend, compact }), [own, lend, compact]);\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- closes the layer once after its spec goes away; the guard (!shownSpec) settles after one pass\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 // eslint-disable-next-line react-hooks/refs -- a monotonic \"ever opened\" latch for this render: no re-render, no setState during render\n if (snap !== 'closed') opened.current = true;\n\n // Consume the launch URL before an open layer pushes its Back entry. Keep the parsed request through StrictMode's effect replay.\n const launch = useRef<ReturnType<typeof parseAgentLink> | undefined>(undefined);\n const [firstAgentLink] = useState(agentLink);\n useEffect(() => {\n if (launch.current === undefined) {\n launch.current = firstAgentLink === false ? null : parseAgentLink(window.location.search);\n if (firstAgentLink !== false) {\n const url = new URL(window.location.href);\n if (['agent', 'open', 'input'].some(key => url.searchParams.has(key))) {\n for (const key of ['agent', 'open', 'input']) url.searchParams.delete(key);\n history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);\n }\n }\n }\n const link = launch.current;\n if (!link) return;\n const which = link.agent === 'improve' ? 'improve' : 'agent';\n open(which, link.open);\n const spec = specs.current[which];\n if (link.input !== 'text' || !spec) return;\n // The drawer's slide and initial focus finish first. Voice deliberately leaves focus off the composer and never calls start().\n const timer = setTimeout(() => {\n const current = specs.current;\n if (current.snap !== 'closed' && current.shown === which && controllerOf(current[which]) === controllerOf(spec)) {\n host?.querySelector<HTMLElement>(`[data-spec=${which}] [data-testid=composer-input]`)?.focus({ preventScroll: true });\n }\n }, 520);\n return () => clearTimeout(timer);\n }, [host, open, firstAgentLink]);\n\n const voice = useVoice(agent, locale, outcome => {\n if (outcome === 'drafted') { setShown('agent'); setSnapState('half'); }\n });\n const [talkRecording, setTalkRecording] = useState(false);\n const cancelTalkRecording = useCallback(() => {\n if (talkRecording && (voice.phase === 'starting' || voice.phase === 'recording')) voice.cancel();\n }, [talkRecording, voice]);\n useRecordingCancellation(cancelTalkRecording);\n const previousTalk = useRef<{ controller: NativeChatController; path: string } | undefined>(undefined);\n const focusTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(focusTimer.current), [agent?.controller]);\n const talkAbout = useCallback<AgentLayerApi['talkAbout']>((token, options = {}) => {\n const selected = specs.current.agent;\n if (!selected) return false;\n clearTimeout(focusTimer.current);\n const parsed = pieces(token, { mentions: true, skills: [] });\n if (parsed.length !== 1 || parsed[0]?.kind !== 'mention') throw new TypeError('talkAbout requires one @mention token');\n const path = parsed[0].value!;\n const before = previousTalk.current;\n const draft = selected.controller.getSnapshot().draft.text;\n const text = before?.controller === selected.controller ? removeMention(draft, before.path) : draft;\n selected.controller.setText(`${appendToDraft(text, mentionToken(path))} `);\n previousTalk.current = { controller: selected.controller, path };\n open('agent', options.at ?? 'half');\n if (options.voice && selected.voice) {\n setTalkRecording(true);\n // No await or effect: permission belongs to the caller's click or swipe release, even before the chat mounts.\n voice.start();\n } else {\n focusTimer.current = setTimeout(() => {\n if (specs.current.agent?.controller === selected.controller && specs.current.snap !== 'closed' && specs.current.shown === 'agent') host?.querySelector<HTMLElement>('[data-spec=agent] [data-testid=composer-input]')?.focus({ preventScroll: true });\n }, 520);\n }\n return true;\n }, [open, voice, host]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a completed or refused capture resets this gesture's presentation once\n useEffect(() => { if (voice.phase === 'idle') setTalkRecording(false); }, [voice.phase]);\n const revealedFailures = useRef(new Set<number>());\n const voiceSend = agent?.voice?.send, pendingKey = voice.pending?.map(item => `${item.id}:${item.phase}`).join(',');\n useEffect(() => {\n const failure = voice.pending?.findLast(item => item.phase === 'transcribe-failed' || item.phase === 'upload-failed' || item.phase === 'blocked' || item.phase === 'unconfirmed');\n const captureProblem = voiceSend === 'message' && voice.problem;\n const failureId = captureProblem ? voice.captureSequence : failure?.id;\n if (failureId === undefined) { revealedFailures.current.clear(); return; }\n if (revealedFailures.current.has(failureId)) return;\n revealedFailures.current.add(failureId);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reveals a new voice failure once per failure id (the ref guards repeats), so it settles after one pass\n if (!expanded && shown === 'agent' && snap === 'peek') setSnapState('half');\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when a pending item's id or phase changes (pendingKey), not on every new pending array\n }, [expanded, shown, snap, voiceSend, voice.captureSequence, voice.problem, pendingKey]);\n\n // One Back entry for the PM, one more while pointing: Back returns to chat, then closes without leaving the app.\n const improveOpen = snap !== 'closed' && shown === 'improve';\n const [pmFull, setPmFull] = useState<{ readonly full: boolean; readonly collapse: () => void }>({ full: false, collapse: () => {} });\n const onFull = useCallback((full: boolean, collapse: () => void) => setPmFull(current => current.full === full ? current : { full, collapse }), []);\n useBackLayer(improveOpen, close, 'agent');\n useBackLayer(improveOpen && pmFull.full, pmFull.collapse, 'sheet');\n const state: AgentLayerState = snap === 'closed' ? 'closed' : expanded ? 'open' : snap;\n const value = useMemo<Internal>(() => ({\n state, showing: snap === 'closed' ? null : shown, open, close, talkAbout, talkRecording, expanded, agent, improve, snap, setSnap, dockFloating, setDockFloating, showAgent, triggerUntil, voice, peekPoint, tabbar, host, locale, side, setSide, restoreFocus,\n }), [state, snap, shown, open, close, talkAbout, talkRecording, expanded, agent, improve, setSnap, dockFloating, 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}><LoanContext.Provider value={loan}><PmPanelProvider spec={improve} open={improveOpen} close={close} appName={appName} locale={locale} onFull={onFull}>\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 {/* eslint-disable-next-line react-hooks/refs -- reads the \"ever opened\" latch set above in this same render */}\n {host && opened.current && createPortal(<Chats showing={shown} />, host)}\n </div>\n </PmPanelProvider></LoanContext.Provider></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 const voiceRef = useRef(voice);\n useLayoutEffect(() => { voiceRef.current = voice; });\n const cancelHold = useCallback(() => {\n const current = hold.current;\n hold.current = null;\n if (!current) return;\n clearTimeout(current.timer);\n if (current.held) voiceRef.current.cancel();\n }, []);\n useRecordingCancellation(cancelHold);\n const controller = agent?.controller;\n useEffect(() => () => cancelHold(), [controller, cancelHold]);\n const { triggerUntil: triggerUntilRef } = layer;\n if (!agent) return null;\n\n const toggle = () => {\n triggerUntilRef.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 const name = agent.label ?? text.open;\n const Icon = agent.icon;\n return <Button variant=\"ghost\" size=\"icon\" data-agent-trigger=\"\" data-testid=\"agent-trigger\" aria-label={name} title={name} 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('touch-none', className)}>\n {children ?? (Icon ? <Icon className=\"size-5\" /> : <SparklesIcon data-icon=\"inline-start\" aria-hidden=\"true\" />)}\n </Button>;\n}\n\n/** Tap to start; while active, this same control is the composer's replacement recording row. */\nfunction TalkButton() {\n const { voice } = useInternal();\n const busy = voice.phase === 'transcribing';\n return <VoiceMessageButton testId=\"agent-talk\" disabled={busy || (voice.phase === 'idle' && voice.canStart === false)} voice={{\n ...voice, phase: busy ? 'processing' : voice.phase, canStart: voice.canStart !== false,\n pending: voice.pending ?? [], captureSequence: voice.captureSequence ?? 0,\n }} />;\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 {...withChatPlugins(spec.chatProps ?? {})} {...(locale ? { locale } : {})} controller={spec.controller} keyboardInset={false} placement={layer.expanded && !layer.dockFloating ? 'docked' : 'floating'} {...(feedback ? { feedback } : {})}\n {...(role === 'agent' && spec.voice ? { voiceMessage: { transcribe: spec.voice.transcribe, Bubble: PendingVoiceBubble }, tools: <>{spec.chatProps?.tools}<TalkButton /></>, composerRecording: layer.voice.phase === 'starting' || layer.voice.phase === 'recording' ? <TalkButton /> : undefined, pendingVoice: layer.voice.pending } : {})} />\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 } = useInternal();\n const seen = useRef(new Set<Which>());\n // eslint-disable-next-line react-hooks/refs -- a monotonic \"ever shown\" set filled during render: no setState, no re-render\n seen.current.add(showing);\n // eslint-disable-next-line react-hooks/refs -- reads the \"ever shown\" set filled above in this same render\n const seenAgent = seen.current.has('agent');\n return <>\n {agent && seenAgent && <SpecChat spec={agent} visible={showing === 'agent'} role=\"agent\" />}\n </>;\n}\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.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 variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-back\" aria-label={text.back} title={text.back} onClick={layer.showAgent}><ArrowLeftIcon data-icon=\"inline-start\" 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 variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-expand\" aria-label={text.expand} title={text.expand} onClick={() => layer.setSnap('full')}><ChevronUpIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {compactSnap === 'full' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"agent-layer-collapse\" aria-label={text.collapse} title={text.collapse} onClick={() => layer.setSnap('half')}><ChevronDownIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n <Button variant=\"ghost\" size=\"icon\" ref={closeRef} data-testid=\"agent-layer-close\" aria-label={text.close} title={text.close} onClick={layer.close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nfunction useTitle(): { text: AppShellText; title: string } {\n const layer = useInternal();\n const spec = layer.showing === 'improve' ? hostSpec(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 liveViewport = 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' && layer.showing !== 'improve' && !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 // While the person talks the sheet holds still: the viewport height and the keyboard inset are pinned at what they were when the recording\n // began, the composer takes no focus and no keyboard, so nothing moves under the finger on the microphone.\n const sheet = useRef<HTMLDivElement>(null);\n const idleViewport = useRef(liveViewport);\n // eslint-disable-next-line react-hooks/refs -- ref cache: the last viewport seen while not recording, pinned during a recording; no setState during render\n if (!recording) idleViewport.current = liveViewport;\n // eslint-disable-next-line react-hooks/refs -- reads the pinned viewport cached above in this same render\n const vv = recording ? idleViewport.current : liveViewport;\n const [pinnedBottom, setPinnedBottom] = useState<string | undefined>();\n useLayoutEffect(() => {\n const popup = sheet.current?.closest('[data-slot=drawer-popup]');\n if (!recording || !sheet.current) return;\n if (popup) setPinnedBottom(getComputedStyle(popup).bottom);\n const inputs = [...sheet.current.querySelectorAll<HTMLTextAreaElement>('[data-testid=composer-input]')].map(input => ({ input, mode: input.getAttribute('inputmode'), readOnly: input.readOnly }));\n for (const { input } of inputs) { input.readOnly = true; input.setAttribute('inputmode', 'none'); }\n return () => {\n setPinnedBottom(undefined);\n for (const { input, mode, readOnly } of inputs) { input.readOnly = readOnly; if (mode === null) input.removeAttribute('inputmode'); else input.setAttribute('inputmode', mode); }\n };\n }, [recording]);\n\n // While « Améliorer l'app » shows, the PM panel owns the layer's Back entry (AgentLayerProvider).\n useBackLayer((snap === 'half' || snap === 'full') && layer.showing !== 'improve', 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-peek-height': `${PEEK_REM}rem`, '--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)',\n ...(pinnedBottom ? { bottom: pinnedBottom } : {}) } 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 if (isOverlayEvent(details)) { details.cancel(); 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%_-_var(--app-shell-peek-height))_0)]')}>\n <BaseDrawerParts.Drawer.VirtualKeyboardProvider>\n <div ref={sheet} data-testid=\"agent-layer-sheet\" onFocusCapture={event => { if (!recording && (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]_[data-slot=input-group]]:flex-row! [&_[data-testid=composer]_[data-slot=input-group]]:items-center',\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 voice.phase !== 'starting' && voice.phase !== 'recording' && '[&_[data-testid=composer-controls]]:w-auto [&_[data-testid=composer-controls]]:shrink-0 [&_[data-testid=composer-controls]]:py-0 [&_[data-testid=composer-controls]>span.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 aria-hidden=\"true\" data-testid=\"agent-listening\" data-phase={voice.phase} className=\"sr-only\">\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\" />{voiceClock(voice.seconds)}</> : `${text.listening} ${voiceClock(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 </BaseDrawerParts.Drawer.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' && layer.showing !== 'improve';\n const floating = api.floating;\n // The chats report this as their placement: the phone drawer and the floating panel are `floating`, the dock is `docked`.\n const { setDockFloating } = layer;\n useLayoutEffect(() => { setDockFloating(floating); return () => setDockFloating(false); }, [floating, setDockFloating]);\n const box = useRef<HTMLDivElement>(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 const { restoreFocus: restoreFocusRef } = layer;\n const openRef = useRef(open);\n useLayoutEffect(() => { openRef.current = open; });\n useLayoutEffect(() => {\n const lost = !document.activeElement || document.activeElement === document.body;\n const wanted = restoreFocusRef.current || (openRef.current && lost && api.placement !== 'closed');\n restoreFocusRef.current = false;\n if (wanted) box.current?.querySelector<HTMLElement>('[data-dock-control]')?.focus({ preventScroll: true });\n }, [api.placement, side, restoreFocusRef]);\n const drag = useDraggable({ box, position: open ? pos : null, onPositionChange: setPos, enabled: floating });\n const act = (run: () => void) => () => { restoreFocusRef.current = true; run(); };\n const control = (id: string, label: string, Icon: ComponentType<{ className?: string }>, run: () => void) =>\n <Button variant=\"ghost\" size=\"icon\" key={id} data-testid={`agent-layer-${id}`} data-dock-control=\"\" aria-label={label} title={label} onClick={act(run)}><Icon data-icon=\"inline-start\" 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 // Floating, the host's Card is drawn behind the layer (a sibling layer, so docking or floating never remounts the chat).\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 relative flex min-h-0 flex-col',\n floating ? 'fixed isolate z-(--app-shell-z-layer) h-[min(36rem,calc(100dvh-2rem))] w-[min(24rem,calc(100vw-2rem))]'\n : 'h-full min-w-0 flex-1 bg-background text-foreground')}>\n {floating && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {!floating && <Separator orientation=\"vertical\" className={cn('absolute inset-y-0', side === 'right' ? 'left-0' : 'right-0')} />}\n <Header text={text} title={title} controls={controls} className={cn('pt-2', floating && 'cursor-move touch-none select-none')} {...drag} />\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' && layer.showing !== 'improve'} 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);\n const session = pointingAgent ? layer.agent?.feedback : undefined;\n const commentingAgent = useCommenting(layer.agent?.feedback);\n const commenting = commentingAgent ? layer.agent?.feedback : undefined;\n // Back ends pointing first (the `point` priority), like Done: the notes wait for the next message. The layer keeps its own history entry underneath.\n useBackLayer(session !== undefined, () => { void session?.done(); }, 'point');\n if (!layer.agent && !layer.improve) return null;\n const pointed = layer.agent;\n const transcribe = (pointed?.voice ?? layer.agent?.voice)?.transcribe;\n const locale = pointed?.chatProps?.locale ?? layer.locale;\n // Comment mode shows its banner either way: on a phone (point mode) above the tab bar, outside the layer, while the compact sheet steps\n // aside; on a wide screen at the top, the dock beside the app. The note bubble is here only while pointing (else inside the layer).\n return <>\n {!layer.expanded && <CompactSheet pointing={session !== undefined} />}\n {commenting && <CommentBanner session={commenting} bottomOffset={`${layer.tabbar}px`} className=\"z-[60]\" {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />}\n {session && <NoteBubble session={session} {...(transcribe ? { transcribe } : {})} {...(locale === 'fr' ? { labels: feedbackLabelsFr } : {})} />}\n </>;\n}\n",
28
28
  "type": "registry:component",
29
29
  "target": "components/app-shell/agent-layer.tsx"
30
30
  },
@@ -40,12 +40,6 @@
40
40
  "type": "registry:lib",
41
41
  "target": "components/app-shell/agent-link.ts"
42
42
  },
43
- {
44
- "path": "registry/app-shell/back.ts",
45
- "content": "// The one Back manager of the app shell. A layer that is open (the agent sheet, another sheet, point mode, a pushed screen) asks for one\n// history entry with the same URL; the browser's Back closes the topmost layer instead of leaving the page, and closing a layer any\n// other way removes its entry, so the history stays as it was. Hosts: the AgentLayer (order `agent`) and, later, sheets and stacks.\nimport { useEffect, useLayoutEffect, useRef } from 'react';\n\n/** The fixed priority of Back: `point` is handled first, then an open `sheet`, then the `agent` layer, then the `stack`, then the browser. */\nexport type BackOrder = 'point' | 'sheet' | 'agent' | 'stack';\nconst RANK: Readonly<Record<BackOrder, number>> = { point: 0, sheet: 1, agent: 2, stack: 3 };\n\ninterface Layer { readonly order: BackOrder; readonly close: () => void; popped: boolean }\n\nconst layers = new Set<Layer>();\n/** `popstate` events caused by our own `history.back()`, which must not close anything. */\nlet ignored = 0;\nlet listening = false;\n/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof history === 'undefined' ? undefined : (history.state as { appShell?: unknown } | null)?.appShell;\n/** Layers that opened while our own `history.back()` was still pending: they push their entry once it has landed (a push in between cancels it). */\nconst waiting: (() => void)[] = [];\n\n/**\n * Runs `action` once the history entry of a sheet that is closing has really gone. A choice that navigates (the app's router replaces the\n * URL) must not run before: the sheet's own `history.back()` would undo it. Call it in the same handler that closes the sheet.\n */\nexport function afterBack(action: () => void): void {\n // The sheet's effect cleanup (which starts its `history.back()`) runs after this handler returns.\n setTimeout(() => { if (ignored > 0) waiting.push(action); else action(); }, 0);\n}\n\n/**\n * Runs `action` after the open stack layers (a ListDetail's pushed detail) have gone back. A nav choice that replaces the URL must not\n * run before: the stack layer's cleanup `history.back()` would restore the detail's URL over it. The layers are released here, not in\n * their cleanup (which only runs once the choice has hidden the screen, too late), and their cleanup then does nothing.\n */\nexport function afterStacksBack(action: () => void): void {\n const stacks = [...layers].filter(layer => layer.order === 'stack' && !layer.popped);\n if (!stacks.length) { action(); return; }\n waiting.push(action);\n for (const layer of stacks) { layer.popped = true; layers.delete(layer); ignored += 1; history.back(); }\n}\n\nconst topmost = (): Layer | undefined => [...layers].toSorted((a, b) => RANK[a.order] - RANK[b.order])[0];\n\nfunction onPopState(): void {\n if (ignored > 0) {\n ignored -= 1;\n if (ignored === 0) for (const start of waiting.splice(0)) start();\n return;\n }\n const top = topmost();\n if (!top) return;\n // The entry is already gone: the layer closes without calling `history.back()` again.\n top.popped = true;\n top.close();\n}\n\n/**\n * While `open`, the layer owns one history entry (`history.pushState({ appShell: order }, '')`, same URL). `popstate` closes only the\n * topmost open layer. Any other close (the effect ends because `open` became false or the component unmounted) calls `history.back()`\n * once, and that `popstate` is ignored. `close` may change between renders.\n */\nexport function useBackLayer(open: boolean, close: () => void, order: BackOrder): void {\n const latest = useRef(close);\n useLayoutEffect(() => { latest.current = close; });\n useEffect(() => {\n if (!open) return;\n const layer: Layer = { order, close: () => latest.current(), popped: false };\n let started = false;\n const start = () => {\n started = true;\n // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n const adopt = persisted === order;\n persisted = undefined;\n if (!adopt) history.pushState({ appShell: order }, '');\n layers.add(layer);\n };\n if (!listening) { window.addEventListener('popstate', onPopState); listening = true; }\n // A sheet that closes as this layer opens (the account menu choosing « Améliorer l'app ») is still going back: wait for it.\n if (ignored > 0) waiting.push(start); else start();\n return () => {\n const at = waiting.indexOf(start);\n if (at >= 0) waiting.splice(at, 1);\n if (!started) return;\n layers.delete(layer);\n if (!layer.popped) { ignored += 1; history.back(); }\n };\n }, [open, order]);\n}\n",
46
- "type": "registry:lib",
47
- "target": "components/app-shell/back.ts"
48
- },
49
43
  {
50
44
  "path": "registry/app-shell/use-visual-viewport.ts",
51
45
  "content": "// Compatibility export for installed apps; the shared presentation helper owns the subscription.\nexport { useVisualViewport } from '../utils/use-media';\nexport type { VisualViewportBox } from '../utils/use-media';\n",
@@ -54,7 +48,7 @@
54
48
  },
55
49
  {
56
50
  "path": "registry/app-shell/shell-layer.ts",
57
- "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, useLayoutEffect, 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 const wanted = options?.confirmBack;\n useLayoutEffect(() => { confirmBack.current = wanted; });\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",
51
+ "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, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { useBackLayer } from '../utils/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 const wanted = options?.confirmBack;\n useLayoutEffect(() => { confirmBack.current = wanted; });\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",
58
52
  "type": "registry:lib",
59
53
  "target": "components/app-shell/shell-layer.ts"
60
54
  },
@@ -66,13 +60,13 @@
66
60
  },
67
61
  {
68
62
  "path": "registry/app-shell/app-shell.tsx",
69
- "content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »), or with `compactNav=\"sheet\"` no bar and the sidebar as a sheet from the header. Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useEffect, useState } from 'react';\nimport { afterStacksBack } from './back';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { ImproveLayout } from '../pm-panel/pm-panel';\nimport type { ImproveProp } from '../pm-panel/pm-panel';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport { ScreenHeaderSlotContext } from '../utils/screen-header-slot';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n /** Phone navigation: `tabs` (default) is the bottom tab bar; `sheet` has no bar, the header's leading button opens the sidebar (the nav items, a screen's `sidebar` content and the account) as a sheet. */\n readonly compactNav?: 'tabs' | 'sheet';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\n /** The product manager: « Améliorer l'app » in the account menu, the PM panel and the point layer. One line: `improve={{ agent: 'pm' }}` (the kit opens the app's PM door); or a host `ImproveSpec`. */\n readonly improve?: ImproveProp;\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n /**\n * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls, slot }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean; readonly slot: HTMLElement | null }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}><ScreenHeaderSlotContext.Provider value={item.id === activeId ? slot : null}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenHeaderSlotContext.Provider></ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ compactNav, layout, rail, title, nav, agent, improve, account, route, text }: { readonly compactNav: NonNullable<AppShellProps['compactNav']>; readonly rail: NonNullable<AppShellProps['rail']>; readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly improve: boolean; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const compact = useIsCompact();\n const sheet = compact && compactNav === 'sheet';\n const [accountOpen, setAccountOpen] = useState(false);\n // The header's slot for the screen on show (an embedded AgentWorkspace puts its bar's controls there).\n const [slot, setSlot] = useState<HTMLElement | null>(null);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n // A nav choice waits for an open detail's stack entry to go back, so the app's router can replace the URL without that Back restoring\n // the detail's. Choosing the screen already on show changes nothing and keeps the detail.\n const choose = (id: string) => { if (id === active.id) route.onChange(id); else afterStacksBack(() => route.onChange(id)); };\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {(!compact || sheet) && <AppSidebar rail={sheet ? 'icon' : rail} title={title} items={nav} activeId={active.id} onChange={choose} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <ImproveLayout screen={active.label}navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} menu={sheet} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n </ImproveLayout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? (improve && 'chatProps' in improve ? improve : undefined))?.chatProps?.locale ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
63
+ "content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »), or with `compactNav=\"sheet\"` no bar and the sidebar as a sheet from the header. Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useEffect, useState, useSyncExternalStore } from 'react';\nimport { afterStacksBack } from '../utils/back';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { ImproveLayout, PmStandalone } from '../pm-panel/pm-panel';\nimport type { ImproveProp } from '../pm-panel/pm-panel';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport { ScreenHeaderSlotContext } from '../utils/screen-header-slot';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n /** Phone navigation: `tabs` (default) is the bottom tab bar; `sheet` has no bar, the header's leading button opens the sidebar (the nav items, a screen's `sidebar` content and the account) as a sheet. */\n readonly compactNav?: 'tabs' | 'sheet';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\n /** The product manager: « Améliorer l'app » in the account menu, the PM panel and the point layer. One line: `improve={{ agent: 'pm' }}` (the kit opens the app's PM door); or a host `ImproveSpec`. */\n readonly improve?: ImproveProp;\n /** With `improve`, `?pm=standalone` shows only the PM. These parent origins may give it their tokens (default: `PM_HOST_ORIGINS`, Studio). */\n readonly pmHostOrigins?: readonly string[];\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n /**\n * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls, slot }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean; readonly slot: HTMLElement | null }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}><ScreenHeaderSlotContext.Provider value={item.id === activeId ? slot : null}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenHeaderSlotContext.Provider></ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ compactNav, layout, rail, title, nav, agent, improve, account, route, text }: { readonly compactNav: NonNullable<AppShellProps['compactNav']>; readonly rail: NonNullable<AppShellProps['rail']>; readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly improve: boolean; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const compact = useIsCompact();\n const sheet = compact && compactNav === 'sheet';\n const [accountOpen, setAccountOpen] = useState(false);\n // The header's slot for the screen on show (an embedded AgentWorkspace puts its bar's controls there).\n const [slot, setSlot] = useState<HTMLElement | null>(null);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n // A nav choice waits for an open detail's stack entry to go back, so the app's router can replace the URL without that Back restoring\n // the detail's. Choosing the screen already on show changes nothing and keeps the detail.\n const choose = (id: string) => { if (id === active.id) route.onChange(id); else afterStacksBack(() => route.onChange(id)); };\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {(!compact || sheet) && <AppSidebar rail={sheet ? 'icon' : rail} title={title} items={nav} activeId={active.id} onChange={choose} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <ImproveLayout screen={active.label}navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} menu={sheet} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n </ImproveLayout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nconst never = () => () => {};\nconst standaloneAddress = () => new URLSearchParams(window.location.search).get('pm') === 'standalone';\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? (improve && 'chatProps' in improve ? improve : undefined))?.chatProps?.locale ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n const standalone = useSyncExternalStore(never, standaloneAddress, () => false);\n if (standalone && improve) return <PmStandalone spec={improve} appName={title} locale={props.locale} hostOrigins={props.pmHostOrigins} />;\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
70
64
  "type": "registry:component",
71
65
  "target": "components/app-shell/app-shell.tsx"
72
66
  },
73
67
  {
74
68
  "path": "registry/app-shell/list-detail.tsx",
75
- "content": "'use client';\n\n// The one list/detail primitive of layout B. Expanded: the list (20rem, or a `railWidth` rail when `collapsible` and folded; its own scroll) and the detail side by side. Compact: one pane at a\n// time; opening a detail is a stack step (one history entry, Back returns to the list) and the list stays mounted, hidden, so its scroll is\n// kept. Host concern: Pi has no layout. A deep link that mounts with a detail needs nothing more: the Back manager's entry sits above the\n// one the page was opened on, and the app's `onBack` (called by Back, and by « ← » which is Back) clears the selection and replaces the URL.\nimport { useContext, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { ScreenActiveContext } from './agent-context';\nimport { useBackLayer } from './back';\nimport { useIsCompact } from './compact';\nimport { useScreenHeader } from './header';\n\nexport interface ListDetailProps {\n /** The list pane (the app's own list; a card list when compact is the app's choice through `useIsCompact`). */\n readonly list: ReactNode | ((state: { readonly collapsed: boolean }) => ReactNode);\n /** `null` = nothing selected. */\n readonly detail: ReactNode | null;\n /** Clears the selection and replaces the URL: called by compact Back and by the header's « ← » (which goes through the browser's Back). */\n readonly onBack: () => void;\n /** The detail's header title when compact (default: the screen's own). */\n readonly title?: string;\n /** Wide screens only: the list pane can fold to a narrow rail (`railWidth`), with a toggle on its edge. Give `list` as a function to render the rail's content. Default: fixed 20rem, no toggle. */\n readonly collapsible?: boolean;\n /** Controlled fold state; without it the pane keeps its own (starting open). */\n readonly collapsed?: boolean;\n readonly onCollapsedChange?: (collapsed: boolean) => void;\n /** The rail's width when collapsed (any CSS length). Default `3rem`, the shell's icon rail. */\n readonly railWidth?: string;\n /** The toggle's accessible names. Default English. */\n readonly labels?: { readonly collapse: string; readonly expand: string };\n}\n\n/** Which panes show: both when expanded; when compact the detail if one is selected, else the list. */\nexport function listDetailView(compact: boolean, detail: ReactNode | null): 'list' | 'detail' | 'both' {\n return !compact ? 'both' : detail === null ? 'list' : 'detail';\n}\n\nexport function ListDetail({ list, detail, onBack, title, collapsible, collapsed: controlled, onCollapsedChange, railWidth = '3rem', labels = { collapse: 'Collapse the list', expand: 'Expand the list' } }: ListDetailProps) {\n const compact = useIsCompact();\n const [own, setOwn] = useState(false);\n const folds = Boolean(collapsible) && !compact;\n const collapsed = folds && (controlled ?? own);\n const toggle = () => { if (controlled === undefined) setOwn(!collapsed); onCollapsedChange?.(!collapsed); };\n const active = useContext(ScreenActiveContext);\n const view = listDetailView(compact, detail);\n const stacked = view === 'detail';\n useBackLayer(active && stacked, onBack, 'stack');\n // « ← » is the browser's Back, so there is one path: the popstate calls `onBack`, and the URL the entry below holds is the one the app then replaces.\n useScreenHeader(stacked ? { back: () => history.back(), ...(title ? { title } : {}) } : {}, { overlay: true });\n return <div data-testid=\"list-detail\" data-view={view} className=\"flex h-full min-h-0\">\n <div hidden={view === 'detail'} data-testid=\"list-pane\" data-collapsed={folds ? collapsed : undefined} style={collapsed ? { width: railWidth } : undefined}\n className={cn('min-h-0 overflow-y-auto overscroll-contain', compact ? 'w-full' : collapsed ? 'shrink-0' : 'w-80 shrink-0')}>{folds && <div className={cn('flex p-1', collapsed ? 'justify-center' : 'justify-end')}>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"list-toggle\" aria-expanded={!collapsed} aria-label={collapsed ? labels.expand : labels.collapse} title={collapsed ? labels.expand : labels.collapse} onClick={toggle}>\n {collapsed ? <ChevronsRightIcon /> : <ChevronsLeftIcon />}\n </Button>\n </div>}\n {typeof list === 'function' ? list({ collapsed }) : list}</div>\n {!compact && view !== 'detail' && <Separator orientation=\"vertical\" />}\n <div hidden={view === 'list'} data-testid=\"detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain', detail === null && 'bg-muted/30')}>{detail}</div>\n </div>;\n}\n",
69
+ "content": "'use client';\n\n// The one list/detail primitive of layout B. Expanded: the list (20rem, or a `railWidth` rail when `collapsible` and folded; its own scroll) and the detail side by side. Compact: one pane at a\n// time; opening a detail is a stack step (one history entry, Back returns to the list) and the list stays mounted, hidden, so its scroll is\n// kept. Host concern: Pi has no layout. A deep link that mounts with a detail needs nothing more: the Back manager's entry sits above the\n// one the page was opened on, and the app's `onBack` (called by Back, and by « ← » which is Back) clears the selection and replaces the URL.\nimport { useContext, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { ScreenActiveContext } from './agent-context';\nimport { useBackLayer } from '../utils/back';\nimport { useIsCompact } from './compact';\nimport { useScreenHeader } from './header';\n\nexport interface ListDetailProps {\n /** The list pane (the app's own list; a card list when compact is the app's choice through `useIsCompact`). */\n readonly list: ReactNode | ((state: { readonly collapsed: boolean }) => ReactNode);\n /** `null` = nothing selected. */\n readonly detail: ReactNode | null;\n /** Clears the selection and replaces the URL: called by compact Back and by the header's « ← » (which goes through the browser's Back). */\n readonly onBack: () => void;\n /** The detail's header title when compact (default: the screen's own). */\n readonly title?: string;\n /** Wide screens only: the list pane can fold to a narrow rail (`railWidth`), with a toggle on its edge. Give `list` as a function to render the rail's content. Default: fixed 20rem, no toggle. */\n readonly collapsible?: boolean;\n /** Controlled fold state; without it the pane keeps its own (starting open). */\n readonly collapsed?: boolean;\n readonly onCollapsedChange?: (collapsed: boolean) => void;\n /** The rail's width when collapsed (any CSS length). Default `3rem`, the shell's icon rail. */\n readonly railWidth?: string;\n /** The toggle's accessible names. Default English. */\n readonly labels?: { readonly collapse: string; readonly expand: string };\n}\n\n/** Which panes show: both when expanded; when compact the detail if one is selected, else the list. */\nexport function listDetailView(compact: boolean, detail: ReactNode | null): 'list' | 'detail' | 'both' {\n return !compact ? 'both' : detail === null ? 'list' : 'detail';\n}\n\nexport function ListDetail({ list, detail, onBack, title, collapsible, collapsed: controlled, onCollapsedChange, railWidth = '3rem', labels = { collapse: 'Collapse the list', expand: 'Expand the list' } }: ListDetailProps) {\n const compact = useIsCompact();\n const [own, setOwn] = useState(false);\n const folds = Boolean(collapsible) && !compact;\n const collapsed = folds && (controlled ?? own);\n const toggle = () => { if (controlled === undefined) setOwn(!collapsed); onCollapsedChange?.(!collapsed); };\n const active = useContext(ScreenActiveContext);\n const view = listDetailView(compact, detail);\n const stacked = view === 'detail';\n useBackLayer(active && stacked, onBack, 'stack');\n // « ← » is the browser's Back, so there is one path: the popstate calls `onBack`, and the URL the entry below holds is the one the app then replaces.\n useScreenHeader(stacked ? { back: () => history.back(), ...(title ? { title } : {}) } : {}, { overlay: true });\n return <div data-testid=\"list-detail\" data-view={view} className=\"flex h-full min-h-0\">\n <div hidden={view === 'detail'} data-testid=\"list-pane\" data-collapsed={folds ? collapsed : undefined} style={collapsed ? { width: railWidth } : undefined}\n className={cn('min-h-0 overflow-y-auto overscroll-contain', compact ? 'w-full' : collapsed ? 'shrink-0' : 'w-80 shrink-0')}>{folds && <div className={cn('flex p-1', collapsed ? 'justify-center' : 'justify-end')}>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"list-toggle\" aria-expanded={!collapsed} aria-label={collapsed ? labels.expand : labels.collapse} title={collapsed ? labels.expand : labels.collapse} onClick={toggle}>\n {collapsed ? <ChevronsRightIcon /> : <ChevronsLeftIcon />}\n </Button>\n </div>}\n {typeof list === 'function' ? list({ collapsed }) : list}</div>\n {!compact && view !== 'detail' && <Separator orientation=\"vertical\" />}\n <div hidden={view === 'list'} data-testid=\"detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain', detail === null && 'bg-muted/30')}>{detail}</div>\n </div>;\n}\n",
76
70
  "type": "registry:component",
77
71
  "target": "components/app-shell/list-detail.tsx"
78
72
  },
@@ -84,25 +78,25 @@
84
78
  },
85
79
  {
86
80
  "path": "registry/app-shell/header-actions.tsx",
87
- "content": "'use client';\n\n// The header actions (`ScreenHeaderAction`) and their renderer, shared by the screen header and the AgentLayer header: up to two icon or label\n// buttons (or links), the rest in « … ». Imports nothing from header.tsx or agent-layer.tsx (the shell's import graph has no cycle).\nimport { useState } from 'react';\nimport type { ComponentType, MouseEvent } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { afterBack } from './back';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\nexport interface ScreenHeaderAction {\n readonly id: string;\n /** The accessible name and the tooltip. */\n readonly label: string;\n readonly icon: ComponentType<Icon>;\n /** `'always'` also shows the label as text next to the icon (default: icon only, at every width). */\n readonly showLabel?: 'always';\n /** The action is a link (an anchor); `onSelect` still runs on click. `target` and `rel` are set only when the host passes them. */\n readonly href?: string;\n readonly target?: string;\n readonly rel?: string;\n readonly onSelect: () => void;\n}\n\n/** One action as a control: an anchor when it has `href`, else a button. `onSelect` is the caller's run (it reads the live action at click time). */\nfunction ActionControl({ action, prefix, onSelect }: { readonly action: ScreenHeaderAction; readonly prefix: string; readonly onSelect: () => void }) {\n const content = <><action.icon data-icon=\"inline-start\" aria-hidden=\"true\" />{action.showLabel === 'always' && action.label}</>;\n return <Button variant=\"ghost\" size={action.showLabel === 'always' ? 'default' : 'icon'}\n {...(action.href !== undefined ? { nativeButton: false, render: <a href={action.href} target={action.target} rel={action.rel} /> } : {})}\n data-testid={`${prefix}-action-${action.id}`} aria-label={action.label} title={action.label} onClick={onSelect}>{content}</Button>;\n}\n\n/** Up to two actions as controls; the rest go in « … » (a sheet when compact, a menu otherwise). `prefix` names the test ids. Shared with the AgentLayer header. */\nexport function HeaderActions({ actions, compact, text, prefix, run }: { readonly actions: readonly ScreenHeaderAction[]; readonly compact: boolean; readonly text: AppShellText; readonly prefix: string; readonly run: (id: string) => void }) {\n const shown = actions.slice(0, 2), extra = actions.slice(2);\n const [sheetOpen, setSheetOpen] = useState(false);\n // A link in the phone sheet: the sheet closes first and a same-tab navigation waits for its history entry (afterBack), so the Back\n // manager never undoes it. A new tab, or a modified click, keeps the browser's own default.\n const sheetLink = (event: MouseEvent<HTMLElement>, action: ScreenHeaderAction) => {\n setSheetOpen(false);\n if ((action.target !== undefined && action.target !== '_self') || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) { afterBack(() => run(action.id)); return; }\n event.preventDefault();\n const href = action.href ?? '';\n afterBack(() => { run(action.id); window.location.assign(href); });\n };\n return <>\n {shown.map(action => <ActionControl key={action.id} action={action} prefix={prefix} onSelect={() => run(action.id)} />)}\n {extra.length > 0 && (compact\n ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid={`${prefix}-more`} aria-label={text.actions} title={text.actions} onClick={() => setSheetOpen(true)}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <AppSheet open={sheetOpen} onOpenChange={setSheetOpen} title={text.actions}>\n {extra.map(action => action.href === undefined\n ? <Button key={action.id} variant=\"ghost\" size=\"lg\" data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={() => { setSheetOpen(false); afterBack(() => run(action.id)); }}><action.icon data-icon=\"inline-start\" />{action.label}</Button>\n : <Button key={action.id} variant=\"ghost\" size=\"lg\" nativeButton={false} render={<a href={action.href} target={action.target} rel={action.rel} />} data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={event => sheetLink(event, action)}><action.icon data-icon=\"inline-start\" />{action.label}</Button>)}\n </AppSheet>\n </>\n : <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid={`${prefix}-more`} aria-label={text.actions} title={text.actions}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"app-shell w-48\">\n {extra.map(action => <DropdownMenuItem key={action.id} data-testid={`${prefix}-action-${action.id}`} className=\"pointer-coarse:min-h-11\" onClick={() => run(action.id)}\n render={action.href === undefined ? undefined : <a href={action.href} target={action.target} rel={action.rel} />}><action.icon className=\"size-4\" />{action.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenu>)}\n </>;\n}\n\n",
81
+ "content": "'use client';\n\n// The header actions (`ScreenHeaderAction`) and their renderer, shared by the screen header and the AgentLayer header: up to two icon or label\n// buttons (or links), the rest in « … ». Imports nothing from header.tsx or agent-layer.tsx (the shell's import graph has no cycle).\nimport { useState } from 'react';\nimport type { ComponentType, MouseEvent } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { afterBack } from '../utils/back';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\nexport interface ScreenHeaderAction {\n readonly id: string;\n /** The accessible name and the tooltip. */\n readonly label: string;\n readonly icon: ComponentType<Icon>;\n /** `'always'` also shows the label as text next to the icon (default: icon only, at every width). */\n readonly showLabel?: 'always';\n /** The action is a link (an anchor); `onSelect` still runs on click. `target` and `rel` are set only when the host passes them. */\n readonly href?: string;\n readonly target?: string;\n readonly rel?: string;\n readonly onSelect: () => void;\n}\n\n/** One action as a control: an anchor when it has `href`, else a button. `onSelect` is the caller's run (it reads the live action at click time). */\nfunction ActionControl({ action, prefix, onSelect }: { readonly action: ScreenHeaderAction; readonly prefix: string; readonly onSelect: () => void }) {\n const content = <><action.icon data-icon=\"inline-start\" aria-hidden=\"true\" />{action.showLabel === 'always' && action.label}</>;\n return <Button variant=\"ghost\" size={action.showLabel === 'always' ? 'default' : 'icon'}\n {...(action.href !== undefined ? { nativeButton: false, render: <a href={action.href} target={action.target} rel={action.rel} /> } : {})}\n data-testid={`${prefix}-action-${action.id}`} aria-label={action.label} title={action.label} onClick={onSelect}>{content}</Button>;\n}\n\n/** Up to two actions as controls; the rest go in « … » (a sheet when compact, a menu otherwise). `prefix` names the test ids. Shared with the AgentLayer header. */\nexport function HeaderActions({ actions, compact, text, prefix, run }: { readonly actions: readonly ScreenHeaderAction[]; readonly compact: boolean; readonly text: AppShellText; readonly prefix: string; readonly run: (id: string) => void }) {\n const shown = actions.slice(0, 2), extra = actions.slice(2);\n const [sheetOpen, setSheetOpen] = useState(false);\n // A link in the phone sheet: the sheet closes first and a same-tab navigation waits for its history entry (afterBack), so the Back\n // manager never undoes it. A new tab, or a modified click, keeps the browser's own default.\n const sheetLink = (event: MouseEvent<HTMLElement>, action: ScreenHeaderAction) => {\n setSheetOpen(false);\n if ((action.target !== undefined && action.target !== '_self') || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) { afterBack(() => run(action.id)); return; }\n event.preventDefault();\n const href = action.href ?? '';\n afterBack(() => { run(action.id); window.location.assign(href); });\n };\n return <>\n {shown.map(action => <ActionControl key={action.id} action={action} prefix={prefix} onSelect={() => run(action.id)} />)}\n {extra.length > 0 && (compact\n ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid={`${prefix}-more`} aria-label={text.actions} title={text.actions} onClick={() => setSheetOpen(true)}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <AppSheet open={sheetOpen} onOpenChange={setSheetOpen} title={text.actions}>\n {extra.map(action => action.href === undefined\n ? <Button key={action.id} variant=\"ghost\" size=\"lg\" data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={() => { setSheetOpen(false); afterBack(() => run(action.id)); }}><action.icon data-icon=\"inline-start\" />{action.label}</Button>\n : <Button key={action.id} variant=\"ghost\" size=\"lg\" nativeButton={false} render={<a href={action.href} target={action.target} rel={action.rel} />} data-testid={`${prefix}-action-${action.id}`} className={SHEET_ROW} onClick={event => sheetLink(event, action)}><action.icon data-icon=\"inline-start\" />{action.label}</Button>)}\n </AppSheet>\n </>\n : <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid={`${prefix}-more`} aria-label={text.actions} title={text.actions}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"app-shell w-48\">\n {extra.map(action => <DropdownMenuItem key={action.id} data-testid={`${prefix}-action-${action.id}`} className=\"pointer-coarse:min-h-11\" onClick={() => run(action.id)}\n render={action.href === undefined ? undefined : <a href={action.href} target={action.target} rel={action.rel} />}><action.icon className=\"size-4\" />{action.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenu>)}\n </>;\n}\n\n",
88
82
  "type": "registry:component",
89
83
  "target": "components/app-shell/header-actions.tsx"
90
84
  },
91
85
  {
92
86
  "path": "registry/app-shell/nav.tsx",
93
- "content": "'use client';\n\n// The navigation: a bottom tab bar when compact, the shadcn sidebar (icon rail plus NavUser, the sidebar-07 and sidebar-08 patterns) when\n// expanded. Choosing an item only calls `route.onChange`: the app's router owns the URL, and the shell adds no history entry.\nimport { useEffect, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { EllipsisIcon, SparklesIcon } from 'lucide-react';\nimport { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { afterBack } from './back';\nimport { NavUser } from './account';\nimport { useAgentLayer } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport type { AccountSpec } from './account';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface NavItem {\n readonly id: string;\n readonly label: string;\n readonly icon: ComponentType<{ className?: string }>;\n readonly badge?: boolean;\n readonly render: () => ReactNode;\n /** The screen's own content inside the shell's sidebar (its list, say) while this item is on show: above the nav items, which move below it. `collapsed` is true on the icon rail, so the content can fold with it. */\n readonly sidebar?: (state: { readonly collapsed: boolean }) => ReactNode;\n}\n\n/** The most tabs the bar shows. */\nconst MAX_TABS = 4;\n\n/** Up to four items are all tabs; more than four give three tabs and the rest behind « Plus ». */\nexport function splitNav<T>(items: readonly T[]): { readonly tabs: readonly T[]; readonly overflow: readonly T[] } {\n return items.length <= MAX_TABS ? { tabs: items, overflow: [] } : { tabs: items.slice(0, MAX_TABS - 1), overflow: items.slice(MAX_TABS - 1) };\n}\n\n/** The host's Badge marks a screen with news. Its classes only place it. */\nconst Dot = ({ className }: { readonly className?: string }) => <Badge aria-hidden=\"true\" data-testid=\"nav-badge\" className={className} />;\n\n/** Publishes the bar's measured height as `--app-shell-tabbar` on the document root (the drawer is portaled, so the shell root is too high up to reach it). */\nfunction useTabbarHeight(bar: { readonly current: HTMLElement | null }, shown: boolean): void {\n useEffect(() => {\n const root = document.documentElement;\n const node = bar.current;\n if (!shown || !node) { root.style.setProperty('--app-shell-tabbar', '0px'); return; }\n const measure = () => root.style.setProperty('--app-shell-tabbar', `${node.getBoundingClientRect().height}px`);\n measure();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : undefined;\n observer?.observe(node);\n return () => { observer?.disconnect(); root.style.removeProperty('--app-shell-tabbar'); };\n }, [bar, shown]);\n}\n\n/** Layout for a tab on the host's Button: icon over label, filling its share of the bar. */\nconst TAB = 'relative h-auto min-w-0 flex-1 flex-col gap-0.5';\n\n/** The tab bar's arrow keys: Left and Right move the focus between the tabs (wrapping). */\nconst arrows = (event: KeyboardEvent<HTMLElement>) => {\n const buttons = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')];\n const at = buttons.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (at < 0 || !step) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]!.focus();\n};\n\n/** The compact tab bar. With one item there is none. Exported for tests. */\nexport function TabBar({ items, activeId, onChange, text }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void; readonly text: AppShellText }) {\n const bar = useRef<HTMLElement>(null);\n const [plusOpen, setPlusOpen] = useState(false);\n const shown = items.length > 1;\n useTabbarHeight(bar, shown);\n if (!shown) return null;\n const { tabs, overflow } = splitNav(items);\n const inOverflow = overflow.find(item => item.id === activeId);\n const tab = (item: NavItem) => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} role=\"tab\" data-testid={`tab-${item.id}`} aria-selected={item.id === activeId} tabIndex={item.id === activeId ? 0 : -1}\n onClick={() => onChange(item.id)} className={TAB}>\n <item.icon />{item.label}{item.badge && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>;\n const Plus = overflow.length ? (inOverflow?.icon ?? EllipsisIcon) : undefined;\n return <nav ref={bar} role=\"tablist\" aria-label={text.tabs} data-testid=\"tabbar\" data-feedback-ignore=\"\" onKeyDown={arrows}\n className=\"relative z-(--app-shell-z-tabs) box-border flex min-h-[calc(3.5rem+env(safe-area-inset-bottom))] shrink-0 bg-background pb-[env(safe-area-inset-bottom)]\">\n <Separator className=\"absolute inset-x-0 top-0\" />\n {tabs.map(tab)}\n {Plus && <>\n <Button variant={inOverflow ? 'secondary' : 'ghost'} role=\"tab\" data-testid=\"tab-plus\" aria-selected={Boolean(inOverflow)} aria-haspopup=\"dialog\" tabIndex={inOverflow ? 0 : -1} onClick={() => setPlusOpen(true)}\n className={TAB}>\n <Plus />{inOverflow?.label ?? text.more}{!inOverflow && overflow.some(item => item.badge) && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>\n <AppSheet open={plusOpen} onOpenChange={setPlusOpen} title={text.more}>\n {overflow.map(item => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} size=\"lg\" data-testid={`plus-${item.id}`} aria-current={item.id === activeId} className={SHEET_ROW}\n onClick={() => { setPlusOpen(false); afterBack(() => onChange(item.id)); }}>\n <item.icon data-icon=\"inline-start\" />{item.label}{item.badge && <Dot className=\"ml-auto\" />}\n </Button>)}\n </AppSheet>\n </>}\n </nav>;\n}\n\n/** The expanded sidebar: the app title, the nav items (none with one item) and the account in the footer. */\nexport function AppSidebar({ rail = 'icon', title, items, activeId, onChange, agent, account, improve, text }: {\n readonly rail?: 'icon' | 'hover'; readonly title: string; readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void;\n readonly agent: AgentSpec | undefined; readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText;\n}) {\n const { setOpen, setOpenMobile, state, isMobile } = useSidebar();\n // In the phone sheet, choosing a screen closes it.\n const choose = (id: string) => { setOpenMobile(false); onChange(id); };\n const own = items.find(item => item.id === activeId)?.sidebar?.({ collapsed: state === 'collapsed' && !isMobile });\n const layer = useAgentLayer();\n const pressed = layer.state !== 'closed' && layer.showing === 'agent';\n const AgentIcon = agent?.icon ?? SparklesIcon;\n const agentLabel = agent?.label ?? agent?.chatProps?.labels?.title ?? text.assistant;\n const sidebar = <Sidebar collapsible=\"icon\" variant=\"inset\" data-testid=\"app-sidebar\">\n {/* The phone sheet is portaled out of the shell root, so the `.app-shell` reset would not reach its buttons: this wrapper carries it (`contents`: no box of its own). */}\n <div className=\"app-shell contents\">\n <SidebarHeader><p data-testid=\"sidebar-title\" className=\"m-0 truncate px-2 py-1 text-base font-semibold group-data-[collapsible=icon]:hidden\">{title}</p></SidebarHeader>\n <SidebarContent>\n {own != null && <div data-testid=\"sidebar-screen\" className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{own}</div>}\n {items.length > 1 && <SidebarGroup className={own != null ? 'shrink-0' : undefined}><SidebarGroupContent><SidebarMenu>\n {items.map(item => <SidebarMenuItem key={item.id}>\n <SidebarMenuButton isActive={item.id === activeId} tooltip={item.label} data-testid={`nav-${item.id}`} className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" onClick={() => choose(item.id)}>\n <item.icon />\n <span>{item.label}</span>\n {item.badge && <Dot className=\"ml-auto group-data-[collapsible=icon]:hidden\" />}\n </SidebarMenuButton>\n </SidebarMenuItem>)}\n </SidebarMenu></SidebarGroupContent></SidebarGroup>}\n {/* The assistant is a layer, never a destination: no route, it toggles the right panel. */}\n {agent && <>\n {items.length > 1 && <SidebarSeparator />}\n <SidebarGroup><SidebarGroupContent><SidebarMenu><SidebarMenuItem>\n <SidebarMenuButton isActive={pressed} aria-pressed={pressed} tooltip={agentLabel} data-testid=\"nav-agent\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n onClick={() => { setOpenMobile(false); if (pressed) layer.close(); else layer.open('agent', 'half'); }}>\n <AgentIcon /><span>{agentLabel}</span>\n </SidebarMenuButton>\n </SidebarMenuItem></SidebarMenu></SidebarGroupContent></SidebarGroup>\n </>}\n </SidebarContent>\n <SidebarFooter><NavUser account={account} improve={improve} text={text} /></SidebarFooter>\n <SidebarRail />\n </div>\n </Sidebar>;\n if (rail !== 'hover') return sidebar;\n // Reserve the native inset icon gap even while the fixed sidebar reveals its labels over the page.\n return <div data-rail=\"hover\" className=\"relative z-20 w-[calc(var(--sidebar-width-icon)+(--spacing(4)))] shrink-0\"\n onPointerEnter={event => { if (event.pointerType === 'mouse' || event.pointerType === 'pen') setOpen(true); }}\n onPointerLeave={() => setOpen(false)}\n onFocusCapture={event => { if (event.target.matches(':focus-visible')) setOpen(true); }}\n onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}\n onKeyDownCapture={event => { if (event.key === 'Escape' && event.target instanceof Node && event.currentTarget.contains(event.target)) { event.preventDefault(); event.stopPropagation(); setOpen(false); } }}>\n {sidebar}\n </div>;\n}\n",
87
+ "content": "'use client';\n\n// The navigation: a bottom tab bar when compact, the shadcn sidebar (icon rail plus NavUser, the sidebar-07 and sidebar-08 patterns) when\n// expanded. Choosing an item only calls `route.onChange`: the app's router owns the URL, and the shell adds no history entry.\nimport { useEffect, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { EllipsisIcon, SparklesIcon } from 'lucide-react';\nimport { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { afterBack } from '../utils/back';\nimport { NavUser } from './account';\nimport { useAgentLayer } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport type { AccountSpec } from './account';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface NavItem {\n readonly id: string;\n readonly label: string;\n readonly icon: ComponentType<{ className?: string }>;\n readonly badge?: boolean;\n readonly render: () => ReactNode;\n /** The screen's own content inside the shell's sidebar (its list, say) while this item is on show: above the nav items, which move below it. `collapsed` is true on the icon rail, so the content can fold with it. */\n readonly sidebar?: (state: { readonly collapsed: boolean }) => ReactNode;\n}\n\n/** The most tabs the bar shows. */\nconst MAX_TABS = 4;\n\n/** Up to four items are all tabs; more than four give three tabs and the rest behind « Plus ». */\nexport function splitNav<T>(items: readonly T[]): { readonly tabs: readonly T[]; readonly overflow: readonly T[] } {\n return items.length <= MAX_TABS ? { tabs: items, overflow: [] } : { tabs: items.slice(0, MAX_TABS - 1), overflow: items.slice(MAX_TABS - 1) };\n}\n\n/** The host's Badge marks a screen with news. Its classes only place it. */\nconst Dot = ({ className }: { readonly className?: string }) => <Badge aria-hidden=\"true\" data-testid=\"nav-badge\" className={className} />;\n\n/** Publishes the bar's measured height as `--app-shell-tabbar` on the document root (the drawer is portaled, so the shell root is too high up to reach it). */\nfunction useTabbarHeight(bar: { readonly current: HTMLElement | null }, shown: boolean): void {\n useEffect(() => {\n const root = document.documentElement;\n const node = bar.current;\n if (!shown || !node) { root.style.setProperty('--app-shell-tabbar', '0px'); return; }\n const measure = () => root.style.setProperty('--app-shell-tabbar', `${node.getBoundingClientRect().height}px`);\n measure();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : undefined;\n observer?.observe(node);\n return () => { observer?.disconnect(); root.style.removeProperty('--app-shell-tabbar'); };\n }, [bar, shown]);\n}\n\n/** Layout for a tab on the host's Button: icon over label, filling its share of the bar. */\nconst TAB = 'relative h-auto min-w-0 flex-1 flex-col gap-0.5';\n\n/** The tab bar's arrow keys: Left and Right move the focus between the tabs (wrapping). */\nconst arrows = (event: KeyboardEvent<HTMLElement>) => {\n const buttons = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')];\n const at = buttons.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (at < 0 || !step) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]!.focus();\n};\n\n/** The compact tab bar. With one item there is none. Exported for tests. */\nexport function TabBar({ items, activeId, onChange, text }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void; readonly text: AppShellText }) {\n const bar = useRef<HTMLElement>(null);\n const [plusOpen, setPlusOpen] = useState(false);\n const shown = items.length > 1;\n useTabbarHeight(bar, shown);\n if (!shown) return null;\n const { tabs, overflow } = splitNav(items);\n const inOverflow = overflow.find(item => item.id === activeId);\n const tab = (item: NavItem) => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} role=\"tab\" data-testid={`tab-${item.id}`} aria-selected={item.id === activeId} tabIndex={item.id === activeId ? 0 : -1}\n onClick={() => onChange(item.id)} className={TAB}>\n <item.icon />{item.label}{item.badge && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>;\n const Plus = overflow.length ? (inOverflow?.icon ?? EllipsisIcon) : undefined;\n return <nav ref={bar} role=\"tablist\" aria-label={text.tabs} data-testid=\"tabbar\" data-feedback-ignore=\"\" onKeyDown={arrows}\n className=\"relative z-(--app-shell-z-tabs) box-border flex min-h-[calc(3.5rem+env(safe-area-inset-bottom))] shrink-0 bg-background pb-[env(safe-area-inset-bottom)]\">\n <Separator className=\"absolute inset-x-0 top-0\" />\n {tabs.map(tab)}\n {Plus && <>\n <Button variant={inOverflow ? 'secondary' : 'ghost'} role=\"tab\" data-testid=\"tab-plus\" aria-selected={Boolean(inOverflow)} aria-haspopup=\"dialog\" tabIndex={inOverflow ? 0 : -1} onClick={() => setPlusOpen(true)}\n className={TAB}>\n <Plus />{inOverflow?.label ?? text.more}{!inOverflow && overflow.some(item => item.badge) && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>\n <AppSheet open={plusOpen} onOpenChange={setPlusOpen} title={text.more}>\n {overflow.map(item => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} size=\"lg\" data-testid={`plus-${item.id}`} aria-current={item.id === activeId} className={SHEET_ROW}\n onClick={() => { setPlusOpen(false); afterBack(() => onChange(item.id)); }}>\n <item.icon data-icon=\"inline-start\" />{item.label}{item.badge && <Dot className=\"ml-auto\" />}\n </Button>)}\n </AppSheet>\n </>}\n </nav>;\n}\n\n/** The expanded sidebar: the app title, the nav items (none with one item) and the account in the footer. */\nexport function AppSidebar({ rail = 'icon', title, items, activeId, onChange, agent, account, improve, text }: {\n readonly rail?: 'icon' | 'hover'; readonly title: string; readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void;\n readonly agent: AgentSpec | undefined; readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText;\n}) {\n const { setOpen, setOpenMobile, state, isMobile } = useSidebar();\n // In the phone sheet, choosing a screen closes it.\n const choose = (id: string) => { setOpenMobile(false); onChange(id); };\n const own = items.find(item => item.id === activeId)?.sidebar?.({ collapsed: state === 'collapsed' && !isMobile });\n const layer = useAgentLayer();\n const pressed = layer.state !== 'closed' && layer.showing === 'agent';\n const AgentIcon = agent?.icon ?? SparklesIcon;\n const agentLabel = agent?.label ?? agent?.chatProps?.labels?.title ?? text.assistant;\n const sidebar = <Sidebar collapsible=\"icon\" variant=\"inset\" data-testid=\"app-sidebar\">\n {/* The phone sheet is portaled out of the shell root, so the `.app-shell` reset would not reach its buttons: this wrapper carries it (`contents`: no box of its own). */}\n <div className=\"app-shell contents\">\n <SidebarHeader><p data-testid=\"sidebar-title\" className=\"m-0 truncate px-2 py-1 text-base font-semibold group-data-[collapsible=icon]:hidden\">{title}</p></SidebarHeader>\n <SidebarContent>\n {own != null && <div data-testid=\"sidebar-screen\" className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{own}</div>}\n {items.length > 1 && <SidebarGroup className={own != null ? 'shrink-0' : undefined}><SidebarGroupContent><SidebarMenu>\n {items.map(item => <SidebarMenuItem key={item.id}>\n <SidebarMenuButton isActive={item.id === activeId} tooltip={item.label} data-testid={`nav-${item.id}`} className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" onClick={() => choose(item.id)}>\n <item.icon />\n <span>{item.label}</span>\n {item.badge && <Dot className=\"ml-auto group-data-[collapsible=icon]:hidden\" />}\n </SidebarMenuButton>\n </SidebarMenuItem>)}\n </SidebarMenu></SidebarGroupContent></SidebarGroup>}\n {/* The assistant is a layer, never a destination: no route, it toggles the right panel. */}\n {agent && <>\n {items.length > 1 && <SidebarSeparator />}\n <SidebarGroup><SidebarGroupContent><SidebarMenu><SidebarMenuItem>\n <SidebarMenuButton isActive={pressed} aria-pressed={pressed} tooltip={agentLabel} data-testid=\"nav-agent\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n onClick={() => { setOpenMobile(false); if (pressed) layer.close(); else layer.open('agent', 'half'); }}>\n <AgentIcon /><span>{agentLabel}</span>\n </SidebarMenuButton>\n </SidebarMenuItem></SidebarMenu></SidebarGroupContent></SidebarGroup>\n </>}\n </SidebarContent>\n <SidebarFooter><NavUser account={account} improve={improve} text={text} /></SidebarFooter>\n <SidebarRail />\n </div>\n </Sidebar>;\n if (rail !== 'hover') return sidebar;\n // Reserve the native inset icon gap even while the fixed sidebar reveals its labels over the page.\n return <div data-rail=\"hover\" className=\"relative z-20 w-[calc(var(--sidebar-width-icon)+(--spacing(4)))] shrink-0\"\n onPointerEnter={event => { if (event.pointerType === 'mouse' || event.pointerType === 'pen') setOpen(true); }}\n onPointerLeave={() => setOpen(false)}\n onFocusCapture={event => { if (event.target.matches(':focus-visible')) setOpen(true); }}\n onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}\n onKeyDownCapture={event => { if (event.key === 'Escape' && event.target instanceof Node && event.currentTarget.contains(event.target)) { event.preventDefault(); event.stopPropagation(); setOpen(false); } }}>\n {sidebar}\n </div>;\n}\n",
94
88
  "type": "registry:component",
95
89
  "target": "components/app-shell/nav.tsx"
96
90
  },
97
91
  {
98
92
  "path": "registry/app-shell/account.tsx",
99
- "content": "'use client';\n\n// The account choices: the compact sheet's content (`AccountChoices`) and the expanded sidebar's footer (`NavUser`, the sidebar-07 pattern).\n// Both list « Améliorer l'app » (when the app has an `improve` agent, opened in the AgentLayer), the app's own `menu`, then sign out.\nimport { useId } from 'react';\nimport type { CSSProperties } from 'react';\nimport { ChevronsUpDownIcon, LogOutIcon, MessageSquarePlusIcon, MoonIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { useAgentLayer } from './agent-layer';\nimport { afterBack } from './back';\nimport { Switch } from '@/components/ui/switch';\nimport { DropdownMenuCheckboxItem } from '@/components/ui/dropdown-menu';\nimport { SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface AccountSpec {\n readonly name: string;\n readonly email?: string;\n readonly signOut?: () => void;\n readonly menu: readonly { readonly label: string; readonly onSelect: () => void }[];\n /** The app's resolved theme: one « Mode sombre » switch in the account menu. The host resolves `system` (its preference) to `light` or `dark` before it gets here. Absent: no row. */\n readonly theme?: { readonly value: 'light' | 'dark'; readonly onChange: (value: 'light' | 'dark') => void };\n}\n\n/** « Améliorer l'app »'s row: 52px as a height, not a minimum, so the shell's 44px touch-target `min-height` (more specific) cannot shrink it. Its radius: the theme's large radius plus one step (12px on a 8px theme). */\nconst IMPROVE_ROW = { '--radius': 'var(--app-shell-row-radius)' } as CSSProperties;\n\n/** The switch's state: on is dark. */\nconst darkOn = (theme: NonNullable<AccountSpec['theme']>) => theme.value === 'dark';\n\nfunction ThemeSwitch({ theme, text }: { readonly theme: NonNullable<AccountSpec['theme']>; readonly text: AppShellText }) {\n const id = useId();\n return <div className=\"flex items-center gap-3\">\n <label htmlFor={id} className=\"flex min-h-11 items-center gap-3\"><MoonIcon aria-hidden=\"true\" />{text.darkMode}</label>\n <Switch id={id} data-testid=\"account-theme\" aria-label={text.darkMode} checked={darkOn(theme)} className=\"ml-auto\" onCheckedChange={on => theme.onChange(on ? 'dark' : 'light')} />\n </div>;\n}\n\n/** « Jean Dupont » gives « JD »; one word gives its first letter. */\nexport function initials(name: string): string {\n const letters = name.trim().split(/\\s+/).filter(Boolean).slice(0, 2).map(word => [...word][0]!.toUpperCase());\n return letters.join('') || '?';\n}\n\nexport function AccountAvatar({ account, size }: { readonly account: AccountSpec | undefined; readonly size?: 'default' | 'lg' }) {\n return <Avatar size={size === 'lg' ? 'lg' : 'default'}><AvatarFallback>{initials(account?.name ?? '')}</AvatarFallback></Avatar>;\n}\n\n/** The compact account sheet's content. Every choice closes the sheet first (`done`). */\nexport function AccountChoices({ account, improve, text, done }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText; readonly done: () => void }) {\n const layer = useAgentLayer();\n const choose = (action: () => void) => () => { done(); afterBack(action); };\n return <div data-testid=\"account-menu\" className=\"flex flex-col\" style={{ '--app-shell-row-radius': 'calc(var(--radius) + 4px)' } as CSSProperties}>\n {account && <div className=\"flex items-center gap-3 px-4 py-3\">\n <AccountAvatar account={account} size=\"lg\" />\n <div className=\"min-w-0\"><p className=\"m-0 truncate font-medium\">{account.name}</p>{account.email && <p className=\"m-0 truncate text-xs text-muted-foreground\">{account.email}</p>}</div>\n </div>}\n {/* C1: the first item, 52px on the muted fill, the speech bubble with a plus (icon 20), 15/650. */}\n {improve && <Button variant=\"secondary\" size=\"lg\" data-testid=\"account-improve\" className={`${SHEET_ROW} h-13`} style={IMPROVE_ROW} onClick={choose(() => layer.open('improve'))}><MessageSquarePlusIcon data-icon=\"inline-start\" className=\"size-5\" aria-hidden=\"true\" /><span className=\"text-[0.9375rem] font-pm-title\">{text.improve}</span></Button>}\n {account?.menu.map(entry => <Button key={entry.label} variant=\"ghost\" size=\"lg\" className={SHEET_ROW} onClick={choose(entry.onSelect)}>{entry.label}</Button>)}\n {account?.theme && <ThemeSwitch theme={account.theme} text={text} />}\n {account?.signOut && <Button variant=\"ghost\" size=\"lg\" data-testid=\"account-sign-out\" className={SHEET_ROW} onClick={choose(account.signOut)}><LogOutIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{text.signOut}</Button>}\n </div>;\n}\n\n/** The expanded sidebar footer: avatar, name and a dropdown with the same entries as the compact sheet. */\nexport function NavUser({ account, improve, text }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText }) {\n const layer = useAgentLayer();\n if (!account && !improve) return null;\n return <SidebarMenu><SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger render={<SidebarMenuButton size=\"lg\" data-testid=\"nav-user\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}>\n <AccountAvatar account={account} />\n <div className=\"grid min-w-0 flex-1 text-left text-sm leading-tight\">\n <span className=\"truncate font-medium\">{account?.name ?? text.account}</span>\n {account?.email && <span className=\"truncate text-xs text-muted-foreground\">{account.email}</span>}\n </div>\n <ChevronsUpDownIcon className=\"ml-auto size-4\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"end\" sideOffset={4} className=\"app-shell w-56 min-w-56\">\n {improve && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-improve\" onClick={() => layer.open('improve')}><MessageSquarePlusIcon className=\"size-4\" aria-hidden=\"true\" />{text.improve}</DropdownMenuItem>}\n {account?.menu.map(entry => <DropdownMenuItem key={entry.label} className=\"pointer-coarse:min-h-11\" onClick={entry.onSelect}>{entry.label}</DropdownMenuItem>)}\n {account?.theme && <DropdownMenuCheckboxItem checked={darkOn(account.theme)} closeOnClick data-testid=\"account-theme\" className=\"pointer-coarse:min-h-11\" onCheckedChange={on => account.theme?.onChange(on ? 'dark' : 'light')}><MoonIcon aria-hidden=\"true\" />{text.darkMode}</DropdownMenuCheckboxItem>}\n {account?.signOut && <><DropdownMenuSeparator /><DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-sign-out\" onClick={account.signOut}><LogOutIcon className=\"size-4\" aria-hidden=\"true\" />{text.signOut}</DropdownMenuItem></>}\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem></SidebarMenu>;\n}\n",
93
+ "content": "'use client';\n\n// The account choices: the compact sheet's content (`AccountChoices`) and the expanded sidebar's footer (`NavUser`, the sidebar-07 pattern).\n// Both list « Améliorer l'app » (when the app has an `improve` agent, opened in the AgentLayer), the app's own `menu`, then sign out.\nimport { useId } from 'react';\nimport type { CSSProperties } from 'react';\nimport { ChevronsUpDownIcon, LogOutIcon, MessageSquarePlusIcon, MoonIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { useAgentLayer } from './agent-layer';\nimport { afterBack } from '../utils/back';\nimport { Switch } from '@/components/ui/switch';\nimport { DropdownMenuCheckboxItem } from '@/components/ui/dropdown-menu';\nimport { SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface AccountSpec {\n readonly name: string;\n readonly email?: string;\n readonly signOut?: () => void;\n readonly menu: readonly { readonly label: string; readonly onSelect: () => void }[];\n /** The app's resolved theme: one « Mode sombre » switch in the account menu. The host resolves `system` (its preference) to `light` or `dark` before it gets here. Absent: no row. */\n readonly theme?: { readonly value: 'light' | 'dark'; readonly onChange: (value: 'light' | 'dark') => void };\n}\n\n/** « Améliorer l'app »'s row: 52px as a height, not a minimum, so the shell's 44px touch-target `min-height` (more specific) cannot shrink it. Its radius: the theme's large radius plus one step (12px on a 8px theme). */\nconst IMPROVE_ROW = { '--radius': 'var(--app-shell-row-radius)' } as CSSProperties;\n\n/** The switch's state: on is dark. */\nconst darkOn = (theme: NonNullable<AccountSpec['theme']>) => theme.value === 'dark';\n\nfunction ThemeSwitch({ theme, text }: { readonly theme: NonNullable<AccountSpec['theme']>; readonly text: AppShellText }) {\n const id = useId();\n return <div className=\"flex items-center gap-3\">\n <label htmlFor={id} className=\"flex min-h-11 items-center gap-3\"><MoonIcon aria-hidden=\"true\" />{text.darkMode}</label>\n <Switch id={id} data-testid=\"account-theme\" aria-label={text.darkMode} checked={darkOn(theme)} className=\"ml-auto\" onCheckedChange={on => theme.onChange(on ? 'dark' : 'light')} />\n </div>;\n}\n\n/** « Jean Dupont » gives « JD »; one word gives its first letter. */\nexport function initials(name: string): string {\n const letters = name.trim().split(/\\s+/).filter(Boolean).slice(0, 2).map(word => [...word][0]!.toUpperCase());\n return letters.join('') || '?';\n}\n\nexport function AccountAvatar({ account, size }: { readonly account: AccountSpec | undefined; readonly size?: 'default' | 'lg' }) {\n return <Avatar size={size === 'lg' ? 'lg' : 'default'}><AvatarFallback>{initials(account?.name ?? '')}</AvatarFallback></Avatar>;\n}\n\n/** The compact account sheet's content. Every choice closes the sheet first (`done`). */\nexport function AccountChoices({ account, improve, text, done }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText; readonly done: () => void }) {\n const layer = useAgentLayer();\n const choose = (action: () => void) => () => { done(); afterBack(action); };\n return <div data-testid=\"account-menu\" className=\"flex flex-col\" style={{ '--app-shell-row-radius': 'calc(var(--radius) + 4px)' } as CSSProperties}>\n {account && <div className=\"flex items-center gap-3 px-4 py-3\">\n <AccountAvatar account={account} size=\"lg\" />\n <div className=\"min-w-0\"><p className=\"m-0 truncate font-medium\">{account.name}</p>{account.email && <p className=\"m-0 truncate text-xs text-muted-foreground\">{account.email}</p>}</div>\n </div>}\n {/* C1: the first item, 52px on the muted fill, the speech bubble with a plus (icon 20), 15/650. */}\n {improve && <Button variant=\"secondary\" size=\"lg\" data-testid=\"account-improve\" className={`${SHEET_ROW} h-13`} style={IMPROVE_ROW} onClick={choose(() => layer.open('improve'))}><MessageSquarePlusIcon data-icon=\"inline-start\" className=\"size-5\" aria-hidden=\"true\" /><span className=\"text-[0.9375rem] font-pm-title\">{text.improve}</span></Button>}\n {account?.menu.map(entry => <Button key={entry.label} variant=\"ghost\" size=\"lg\" className={SHEET_ROW} onClick={choose(entry.onSelect)}>{entry.label}</Button>)}\n {account?.theme && <ThemeSwitch theme={account.theme} text={text} />}\n {account?.signOut && <Button variant=\"ghost\" size=\"lg\" data-testid=\"account-sign-out\" className={SHEET_ROW} onClick={choose(account.signOut)}><LogOutIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{text.signOut}</Button>}\n </div>;\n}\n\n/** The expanded sidebar footer: avatar, name and a dropdown with the same entries as the compact sheet. */\nexport function NavUser({ account, improve, text }: { readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText }) {\n const layer = useAgentLayer();\n if (!account && !improve) return null;\n return <SidebarMenu><SidebarMenuItem>\n <DropdownMenu>\n <DropdownMenuTrigger render={<SidebarMenuButton size=\"lg\" data-testid=\"nav-user\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}>\n <AccountAvatar account={account} />\n <div className=\"grid min-w-0 flex-1 text-left text-sm leading-tight\">\n <span className=\"truncate font-medium\">{account?.name ?? text.account}</span>\n {account?.email && <span className=\"truncate text-xs text-muted-foreground\">{account.email}</span>}\n </div>\n <ChevronsUpDownIcon className=\"ml-auto size-4\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"end\" sideOffset={4} className=\"app-shell w-56 min-w-56\">\n {improve && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-improve\" onClick={() => layer.open('improve')}><MessageSquarePlusIcon className=\"size-4\" aria-hidden=\"true\" />{text.improve}</DropdownMenuItem>}\n {account?.menu.map(entry => <DropdownMenuItem key={entry.label} className=\"pointer-coarse:min-h-11\" onClick={entry.onSelect}>{entry.label}</DropdownMenuItem>)}\n {account?.theme && <DropdownMenuCheckboxItem checked={darkOn(account.theme)} closeOnClick data-testid=\"account-theme\" className=\"pointer-coarse:min-h-11\" onCheckedChange={on => account.theme?.onChange(on ? 'dark' : 'light')}><MoonIcon aria-hidden=\"true\" />{text.darkMode}</DropdownMenuCheckboxItem>}\n {account?.signOut && <><DropdownMenuSeparator /><DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"account-sign-out\" onClick={account.signOut}><LogOutIcon className=\"size-4\" aria-hidden=\"true\" />{text.signOut}</DropdownMenuItem></>}\n </DropdownMenuContent>\n </DropdownMenu>\n </SidebarMenuItem></SidebarMenu>;\n}\n",
100
94
  "type": "registry:component",
101
95
  "target": "components/app-shell/account.tsx"
102
96
  },
103
97
  {
104
98
  "path": "registry/app-shell/app-sheet.tsx",
105
- "content": "'use client';\n\n// The one sheet of the shell (account, « Plus », « … », app flows): the host's shadcn `drawer`, a modal bottom sheet. Only one is open at a time\n// (opening one closes any other) and Back closes it (the Back manager's `sheet` layer). The agent layer is not an AppSheet.\nimport type { ReactNode } from 'react';\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';\nimport { useBackLayer } from './back';\nimport { useExclusiveSheet } from './use-exclusive-sheet';\n\n/** A modal bottom sheet with a title. The scrim, Escape, a swipe down and Back close it. */\nexport function AppSheet({ open, onOpenChange, title, children }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly title: string; readonly children: ReactNode }) {\n const close = () => onOpenChange(false);\n useExclusiveSheet(open, close);\n useBackLayer(open, close, 'sheet');\n return <Drawer open={open} onOpenChange={next => { if (!next) close(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"app-sheet\" className=\"app-shell max-h-[85dvh]\">\n <DrawerHeader><DrawerTitle>{title}</DrawerTitle></DrawerHeader>\n <div className=\"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-2 pt-2 pb-[env(safe-area-inset-bottom)]\">{children}</div>\n </DrawerContent>\n </Drawer>;\n}\n\n/** Layout for a full-width sheet row on the host's ghost `Button`: at least 48px tall (44px is the touch minimum). */\nexport const SHEET_ROW = 'min-h-12 w-full justify-start gap-3';\n",
99
+ "content": "'use client';\n\n// The one sheet of the shell (account, « Plus », « … », app flows): the host's shadcn `drawer`, a modal bottom sheet. Only one is open at a time\n// (opening one closes any other) and Back closes it (the Back manager's `sheet` layer). The agent layer is not an AppSheet.\nimport type { ReactNode } from 'react';\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';\nimport { useBackLayer } from '../utils/back';\nimport { useExclusiveSheet } from './use-exclusive-sheet';\n\n/** A modal bottom sheet with a title. The scrim, Escape, a swipe down and Back close it. */\nexport function AppSheet({ open, onOpenChange, title, children }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly title: string; readonly children: ReactNode }) {\n const close = () => onOpenChange(false);\n useExclusiveSheet(open, close);\n useBackLayer(open, close, 'sheet');\n return <Drawer open={open} onOpenChange={next => { if (!next) close(); }} showSwipeHandle swipeDirection=\"down\">\n <DrawerContent data-testid=\"app-sheet\" className=\"app-shell max-h-[85dvh]\">\n <DrawerHeader><DrawerTitle>{title}</DrawerTitle></DrawerHeader>\n <div className=\"flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-2 pt-2 pb-[env(safe-area-inset-bottom)]\">{children}</div>\n </DrawerContent>\n </Drawer>;\n}\n\n/** Layout for a full-width sheet row on the host's ghost `Button`: at least 48px tall (44px is the touch minimum). */\nexport const SHEET_ROW = 'min-h-12 w-full justify-start gap-3';\n",
106
100
  "type": "registry:component",
107
101
  "target": "components/app-shell/app-sheet.tsx"
108
102
  },