@hachej/boring-ui-kit 0.4.45 → 0.4.46

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 (50) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +20 -15
  4. package/r/board-viewer.json +6 -6
  5. package/r/button.json +1 -1
  6. package/r/dock.json +1 -1
  7. package/r/documents.json +12 -12
  8. package/r/feedback-preview.json +1 -1
  9. package/r/feedback.json +6 -6
  10. package/r/file-tree.json +4 -4
  11. package/r/highlights.json +3 -3
  12. package/r/html-viewer.json +3 -3
  13. package/r/looks-viewer.json +2 -2
  14. package/r/maquette-viewer.json +1 -1
  15. package/r/markdown-editor.json +3 -3
  16. package/r/mockup-viewer.json +1 -1
  17. package/r/pi-ambient.json +8 -8
  18. package/r/pi-app-kinds.json +10 -10
  19. package/r/pi-app.json +10 -10
  20. package/r/pi-artifacts.json +2 -2
  21. package/r/pi-chat-approval.json +2 -2
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +2 -2
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +2 -2
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +3 -3
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +3 -3
  33. package/r/placed-agent.json +4 -4
  34. package/r/pm-panel.json +17 -17
  35. package/r/provider-setup.json +1 -1
  36. package/r/registry.json +189 -185
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +6 -6
  39. package/r/task-board.json +2 -2
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/task-mentions.json +4 -4
  42. package/r/version-history.json +2 -2
  43. package/r/viewer-code.json +2 -2
  44. package/r/viewer-frame.json +4 -4
  45. package/r/viewer-html.json +4 -4
  46. package/r/viewer-image.json +2 -2
  47. package/r/viewer-markdown.json +5 -5
  48. package/r/viewer-pdf.json +3 -3
  49. package/r/viewers.json +6 -6
  50. package/r/windows.json +1 -1
package/r/pm-panel.json CHANGED
@@ -4,32 +4,32 @@
4
4
  "title": "Shared PM composer and in-app panel",
5
5
  "description": "« Améliorer l'app »: the embedded AgentWorkspace chat, History, read-only Library and host Tasks panel, with the shared pointing strip, numbered pins and timestamped chips. Docked beside the app on desktop and full screen on phones; PmChat gives Studio the same component.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.45",
8
- "@hachej/boring-feedback@^0.4.45",
9
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.46",
8
+ "@hachej/boring-feedback@^0.4.46",
9
+ "@hachej/boring-files@^0.4.46",
10
10
  "@earendil-works/pi-durable@~1.4.0",
11
11
  "lucide-react@^1.51.0",
12
12
  "react@^19.0.0",
13
13
  "react-dom@^19.0.0"
14
14
  ],
15
15
  "registryDependencies": [
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app-kinds.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-markdown.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-plugins.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-voice.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/feedback.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/task-board.json",
24
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/task-mentions.json",
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-artifacts.json",
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-frame.json"
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-app.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-app-kinds.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-markdown.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-plugins.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-voice.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/feedback.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/task-board.json",
24
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/task-mentions.json",
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/utils.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-artifacts.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/viewer-frame.json"
28
28
  ],
29
29
  "files": [
30
30
  {
31
31
  "path": "registry/pm-panel/pm-panel.tsx",
32
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { ListTodoIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { AgentWorkspace } from '../pi-app/agent-workspace';\nimport type { WorkspaceBarPanel } from '../pi-app/workspace-bar';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard } from '../task-board/task-board';\nimport type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { selectTasks } from '@hachej/boring-ui-kit/task-board';\nimport { requestOutcome } from './pm-task-source';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport { useImproveAgent } from './use-improve-agent';\nimport type { ImproveProp } from './use-improve-agent';\nimport { pmTextFor } from './pm-text';\nimport type { PmPreset } from './pm-composer';\nimport type { PmText } from './pm-text';\nimport { useSiblingPointing } from './point-frames';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { PM_HOST_ORIGINS, usePmHostTheme } from './pm-host-theme';\nimport { PointChipView } from './inline-point-input';\n\nexport { PM_HOST_ORIGINS } from './pm-host-theme';\nexport { PmComposer, PointerIcon } from './pm-composer';\nexport type { PmComposerProps, PmPreset } from './pm-composer';\nexport { usePmPanel } from './use-pm-panel';\nexport type { PmComment, PmPanelState } from './use-pm-panel';\nexport { PointComment } from './point-comment';\nexport { usePmMentions } from './pm-mentions';\nexport { pmTextEn, pmTextFor, pmTextFr } from './pm-text';\nexport type { PmText } from './pm-text';\nexport { PointChipView } from './inline-point-input';\nexport { pointMessage } from './point-message';\nexport type { ImproveSpec, PmVoice, PmTranscript, StudioTaskBoardSeam } from './use-pm-panel';\nexport { useImproveAgent, isImproveAgent } from './use-improve-agent';\nexport type { ImproveAgent, ImproveProp } from './use-improve-agent';\n\ninterface Binding { readonly spec: ImproveSpec; readonly open: boolean; readonly close: () => void; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined }\ninterface Pending { readonly open: boolean; readonly close: () => void; readonly problem: string | undefined; readonly retry: () => void; readonly text: PmText }\nconst PmContext = createContext<Binding | Pending | null>(null);\nconst AppNameContext = createContext('');\nconst defaultPolicy = createPrivacyPolicy();\n// What a mock-up the PM presents may load besides its own inline code: libraries from two public hosts, in the kit's sandboxed preview.\nconst PM_INTERACTIVE = { scriptSources: ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'] };\nasync function imageFiles(files: readonly File[]) {\n return Promise.all(files.map(async (file, index) => {\n if (!/^image\\/(png|jpeg|gif|webp)$/.test(file.type) || file.size > 3 * 1024 * 1024) throw new Error('Joignez une image de moins de 3 Mo.');\n let binary = '';\n for (const byte of new Uint8Array(await file.arrayBuffer())) binary += String.fromCharCode(byte);\n return { id: `pm-file-${Date.now()}-${index}`, name: file.name, content: { type: 'image' as const, mimeType: file.type, data: btoa(binary) } };\n }));\n}\n\n/** One borrowed PM conversation, resolved through the app's existing door on the first open. */\nexport function PmPanelProvider({ spec, open, close, appName, locale, onFull, children }: { readonly spec?: ImproveProp | undefined; readonly open: boolean; readonly close: () => void; readonly appName?: string | undefined; readonly locale?: string | undefined; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined; readonly children: ReactNode }) {\n const resolved = useImproveAgent(spec, open, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const text = pmTextFor(locale);\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, retry: resolved.retry, text } : null, [resolved.spec, resolved.problem, resolved.retry, spec, open, close, onFull, text]);\n return <PmContext.Provider value={value}><AppNameContext.Provider value={appName ?? ''}>{children}</AppNameContext.Provider></PmContext.Provider>;\n}\n\n/** Desktop: beside the pointable app. Phone: full-screen chat, or the in-flow strip while pointing. */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const bound = useContext(PmContext);\n const [page, setPage] = useState<HTMLDivElement | null>(null);\n const binding = bound && 'spec' in bound ? bound : null, pending = bound && !('spec' in bound) ? bound : null;\n const controller = binding?.spec.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n const [pointing, setPointing] = useState(false);\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:flex-row\">\n <div ref={setPage} data-pm-page=\"\" data-pm-screen={screen} className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n {bound && inFrame() && <SiblingPoint page={page} screen={screen ?? ''} bound={bound} />}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={setPointing} />}\n {pending?.open && <PmConnecting text={pending.text} problem={pending.problem} retry={pending.retry} close={pending.close} className=\"absolute inset-0 z-40 md:static md:w-[26rem] md:shrink-0\" />}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !pointing)} className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n\nconst inFrame = () => typeof window !== 'undefined' && window.parent !== window;\n\n/** This page is framed beside a standalone PM: pointing started there runs here, and this frame's own PM steps aside for it. */\nfunction SiblingPoint({ page, screen, bound }: { readonly page: HTMLElement | null; readonly screen: string; readonly bound: Binding | Pending }) {\n const text = 'spec' in bound ? pmTextFor(bound.spec.chatProps?.locale) : bound.text;\n useSiblingPointing({ app: useContext(AppNameContext), page, screen, policy: ('spec' in bound ? bound.spec.policy : undefined) ?? defaultPolicy, text, onStart: bound.close });\n return null;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (pointing: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const returning = useRef(pm.returnToComposer);\n useLayoutEffect(() => { returning.current = pm.returnToComposer; });\n const collapse = useCallback(() => returning.current(), []);\n useLayoutEffect(() => { report(strip); }, [strip, report]);\n const onFull = binding.onFull;\n useEffect(() => { onFull?.(strip, collapse); }, [strip, collapse, onFull]);\n useEffect(() => () => { onFull?.(false, collapse); }, [onFull, collapse]);\n return <section hidden={!binding.open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={strip ? pm.state.mode : 'chat'}\n className={cn('min-h-0 min-w-0 bg-background md:relative md:w-[26rem] md:shrink-0', binding.open && 'flex flex-col', strip ? 'shrink-0' : 'absolute inset-0 z-40')}>\n <PmWorkspace spec={binding.spec} pm={pm} page={page} preset=\"touch\" open={binding.open} close={binding.close} />\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0 z-50 hidden w-px md:block\" />\n </section>;\n}\n\nconst COARSE = '(pointer: coarse)', WIDE = '(min-width: 768px)';\nconst fine = () => !matchMedia(COARSE).matches && matchMedia(WIDE).matches;\nfunction useFinePointer() {\n return useSyncExternalStore(listener => {\n const queries = [matchMedia(COARSE), matchMedia(WIDE)];\n for (const query of queries) query.addEventListener('change', listener);\n return () => { for (const query of queries) query.removeEventListener('change', listener); };\n }, fine, () => false);\n}\n\nfunction PmConnecting({ text, problem, retry, close, className }: { readonly text: PmText; readonly problem: string | undefined; readonly retry: () => void; readonly close?: (() => void) | undefined; readonly className: string }) {\n return <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className={cn('flex min-h-0 flex-col gap-3 bg-background p-4', className)}>\n <h2 className=\"m-0 text-sm\">{text.title}</h2>\n <p role={problem ? 'alert' : 'status'} className=\"m-0 text-sm text-muted-foreground\">{problem ? text.connectionFailed : text.connecting}</p>\n {problem && <Button variant=\"outline\" onClick={retry} data-testid=\"pm-connect-retry\">{text.retry}</Button>}\n {close && <Button variant=\"ghost\" data-testid=\"pm-close\" onClick={close}>{text.close}</Button>}\n </section>;\n}\n\nexport interface PmChatProps {\n /** One line, `{ agent: 'pm' }` (the kit opens the app's PM door), or a host `ImproveSpec`. */\n readonly spec: ImproveProp;\n /** The page this chat points at when it is in the same document. Without it, pointing needs `siblingApp`. */\n readonly page?: HTMLElement | null;\n /** The app's name: this PM sits in a frame beside the app's frame, and pointing runs there. */\n readonly siblingApp?: string;\n readonly screen?: string;\n readonly preset?: PmPreset;\n readonly appName?: string;\n readonly locale?: string;\n readonly close?: (() => void) | undefined;\n /** The bar's title, instead of the app's name (a host that already shows it). */\n readonly title?: string | undefined;\n}\n\n/** The PM, plugged anywhere: the regular chat filling its container. What the PM presents sits beside it when the container is wide enough, otherwise replaces it with Back. */\nexport function PmChat({ spec, appName, locale, close, ...rest }: PmChatProps) {\n const resolved = useImproveAgent(spec, true, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const controller = resolved.spec?.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n if (!resolved.spec) return <PmConnecting text={pmTextFor(locale)} problem={resolved.problem} retry={resolved.retry} close={close} className=\"flex-1\" />;\n return <PmChatSession key={instance.sequence} spec={resolved.spec} close={close} {...rest} />;\n}\n\nfunction PmChatSession({ spec, page = null, siblingApp, screen, preset = 'desktop', close, title }: Omit<PmChatProps, 'spec' | 'appName' | 'locale'> & { readonly spec: ImproveSpec }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' && siblingApp === undefined, sibling: siblingApp });\n return <div data-testid=\"pm-chat\" data-mode={pm.state.mode} className=\"flex min-h-0 min-w-0 flex-1 flex-col\"><PmWorkspace spec={spec} page={page} pm={pm} preset={preset} open close={close} beside={siblingApp !== undefined} title={title} /></div>;\n}\n\n/** `?pm=standalone`: only the PM, full frame, for a host's column beside the app's frame. Same origin and session as the app, no route of its own. */\nexport function PmStandalone({ spec, appName, locale, hostOrigins = PM_HOST_ORIGINS }: { readonly spec: ImproveProp; readonly appName: string; readonly locale?: string | undefined; readonly hostOrigins?: readonly string[] | undefined }) {\n usePmHostTheme(hostOrigins);\n return <main data-testid=\"pm-standalone\" className=\"app-shell flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground\">\n <PmChat spec={spec} appName={appName} siblingApp={appName} preset=\"touch\" title={window.parent === window ? undefined : 'PM'} {...(locale ? { locale } : {})} />\n </main>;\n}\n\n/** Wiring only: the regular agent page, with the existing point composer and a host Tasks panel. */\nfunction PmWorkspace({ spec, pm, page, preset, open, close, beside = false, title }: { readonly title?: string | undefined; readonly spec: ImproveSpec; readonly pm: ReturnType<typeof usePmPanel>; readonly page: HTMLElement | null; readonly preset: PmPreset; readonly open: boolean; readonly close?: (() => void) | undefined; readonly beside?: boolean }) {\n const { text } = pm;\n const files = spec.resources ? { resources: spec.resources } : { resources: undefined };\n // On a phone the chat gives the page its screen while pointing. A PM beside the app's frame covers nothing.\n const phone = usePhone() && !beside;\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const root = useRef<HTMLDivElement | null>(null);\n const kinds = useMemo(() => spec.artifactKinds ?? defaultKinds({ locale: text.locale }), [spec.artifactKinds, text.locale]);\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n const seenKey = `boring.pm.ships.${JSON.stringify(pm.snapshot.identity)}`;\n const [seen, setSeen] = useState<readonly string[]>(() => { try { const value: unknown = JSON.parse(localStorage.getItem(seenKey) ?? '[]'); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } catch { return []; } });\n const ships = spec.tasks ? selectTasks(spec.tasks.tasks, { filter: 'mine', currentUser: spec.tasks.currentUser }).filter(task => !task.technical && requestOutcome(task) === 'live').map(task => `${task.id}:${task.updated_at ?? ''}`) : [];\n const tasksLabel = text.locale === 'fr' ? 'Tâches' : 'Tasks';\n // Beside the app in a host's frame, the host owns the one way to the tasks (Julien, 2026-10-31: no second icon).\n const barPanels: readonly WorkspaceBarPanel[] = beside ? [] : [{ id: 'tasks', label: tasksLabel, icon: ListTodoIcon, dot: spec.tasks?.unread ?? ships.some(ship => !seen.includes(ship)), onOpenChange: spec.tasks?.onOpenChange, onOpen: () => {\n refreshTasks?.(); setSeen(ships); try { localStorage.setItem(seenKey, JSON.stringify(ships)); } catch { /* viewer storage is optional */ }\n }, content: () => <div data-testid=\"pm-task-board-seam\" className=\"flex min-h-0 flex-1 flex-col\">{spec.tasks ? <>{spec.tasks.header}<PmRequests seam={spec.tasks} text={text} /></> : <p role=\"status\" className=\"p-4 text-sm text-muted-foreground\">{text.connectionFailed}</p>}</div> }];\n useEffect(() => {\n if (!pm.focusVersion || !open || strip) return;\n const input = root.current?.querySelector<HTMLElement>('[data-testid=composer-input]');\n input?.focus({ preventScroll: true });\n if (input instanceof HTMLTextAreaElement) input.setSelectionRange(input.value.length, input.value.length);\n }, [pm.focusVersion, open, strip, spec.controller]);\n const pointProps = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n canPoint: pm.canPoint, onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">{text.locale === 'fr' ? 'Transcription…' : 'Transcribing…'}</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n return <ChatTextProvider value={chatTextFor(text.locale)}><div ref={root} className=\"flex min-h-0 flex-1 flex-col\">\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={strip && phone} className={cn('min-h-0 flex-1 flex-col', strip && phone ? 'hidden' : 'flex')}>\n <AgentWorkspace layout=\"embedded\" title={title ?? spec.appName ?? spec.label ?? text.title} locale={text.locale} controller={spec.controller}\n conversationId={spec.conversationId ?? String(pm.snapshot.conversationId)} conversations={spec.conversations ?? spec.chatProps?.conversations}\n {...files} artifactKinds={kinds} interactive={PM_INTERACTIVE} autoOpen={Boolean(spec.resources)} barPanels={barPanels} barOptions={{ panelsFirst: true }}\n controls={close && <Button variant=\"ghost\" size=\"icon\" data-testid=\"pm-close\" aria-label={text.close} title={text.close} onClick={close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n chat={{ ...spec.chatProps, ...(mentions ? { mentions } : {}),\n labels: { ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send },\n onFiles: spec.chatProps?.onFiles ?? imageFiles, sendContext: pm.sendContext, replyError: spec.chatProps?.replyError ?? (() => text.failed),\n renderEntry: entry => pointMessage(entry, mentions, { preset, text }) ?? spec.chatProps?.renderEntry?.(entry),\n replyFooter: reply => { const entry = pm.snapshot.view?.entries.find(entry => String(entry.id) === reply.entryId); return <>{spec.chatProps?.replyFooter?.(reply)}{entry && spec.tasks?.renderRequest?.({ entry, edit })}</>; },\n renderComposer: composer => strip && phone ? null : strip ? <>{beside && pm.state.chips.length > 0 && <div data-testid=\"pm-beside-chips\" className=\"flex flex-wrap gap-1.5 pb-2\">{pm.state.chips.map(chip => <PointChipView key={chip.id} chip={chip} preset={preset} labels={text} onRemove={() => pm.remove(chip.id)} />)}</div>}<PmComposer variant=\"strip\" {...pointProps} /></> : <PmComposer {...pointProps} composer={pm.answerComposer({ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) })} transcript={transcript} /> }} />\n </div>\n {strip && phone && <PmComposer variant=\"strip\" {...pointProps} />}\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\n/** « Mes demandes »: the one board, the viewer's own tasks. The panel's width stacks it; the detail is where a request is answered or withdrawn. */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [selected, setSelected] = useState<string | null>(null);\n const answer = (task: TaskRecord, _question: unknown, value: string) => seam.answer?.(task, value);\n return <TaskBoard tasks={seam.tasks} filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests} locale={text.locale} selectedTask={selected}\n onSelectedTaskChange={id => { setSelected(id); const task = seam.tasks.find(each => each.id === id); if (task) seam.onOpen(task); }}\n actions={task => requestOutcome(task) ? undefined : seam.cardActions?.(task)} onAnswer={answer} onOther={answer} className=\"min-h-0 flex-1\" />;\n}\n",
32
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { ListTodoIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { AgentWorkspace } from '../pi-app/agent-workspace';\nimport type { WorkspaceBarPanel } from '../pi-app/workspace-bar';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard } from '../task-board/task-board';\nimport type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { selectTasks } from '@hachej/boring-ui-kit/task-board';\nimport { requestOutcome } from './pm-task-source';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport { isImproveAgent, useImproveAgent } from './use-improve-agent';\nimport type { ImproveProp } from './use-improve-agent';\nimport { pmTextFor } from './pm-text';\nimport type { PmPreset } from './pm-composer';\nimport type { PmText } from './pm-text';\nimport { useSiblingPointing } from './point-frames';\nimport { createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { PM_HOST_ORIGINS, usePmHostTheme } from './pm-host-theme';\nimport { PointChipView } from './inline-point-input';\n\nexport { PM_HOST_ORIGINS } from './pm-host-theme';\nexport { PmComposer, PointerIcon } from './pm-composer';\nexport type { PmComposerProps, PmPreset } from './pm-composer';\nexport { usePmPanel } from './use-pm-panel';\nexport type { PmComment, PmPanelState } from './use-pm-panel';\nexport { PointComment } from './point-comment';\nexport { usePmMentions } from './pm-mentions';\nexport { pmTextEn, pmTextFor, pmTextFr } from './pm-text';\nexport type { PmText } from './pm-text';\nexport { PointChipView } from './inline-point-input';\nexport { pointMessage } from './point-message';\nexport type { ImproveSpec, PmVoice, PmTranscript, StudioTaskBoardSeam } from './use-pm-panel';\nexport { useImproveAgent, isImproveAgent } from './use-improve-agent';\nexport type { ImproveAgent, ImproveProp } from './use-improve-agent';\n\ninterface Binding { readonly spec: ImproveSpec; readonly open: boolean; readonly close: () => void; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined }\ninterface Pending { readonly open: boolean; readonly close: () => void; readonly problem: string | undefined; readonly retry: () => void; readonly text: PmText }\nconst PmContext = createContext<Binding | Pending | null>(null);\nconst AppNameContext = createContext('');\nconst defaultPolicy = createPrivacyPolicy();\n// What a mock-up the PM presents may load besides its own inline code: libraries from two public hosts, in the kit's sandboxed preview.\nconst PM_INTERACTIVE = { scriptSources: ['https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'] };\nasync function imageFiles(files: readonly File[]) {\n return Promise.all(files.map(async (file, index) => {\n if (!/^image\\/(png|jpeg|gif|webp)$/.test(file.type) || file.size > 3 * 1024 * 1024) throw new Error('Joignez une image de moins de 3 Mo.');\n let binary = '';\n for (const byte of new Uint8Array(await file.arrayBuffer())) binary += String.fromCharCode(byte);\n return { id: `pm-file-${Date.now()}-${index}`, name: file.name, content: { type: 'image' as const, mimeType: file.type, data: btoa(binary) } };\n }));\n}\n\n/** One borrowed PM conversation, resolved through the app's existing door on the first open. */\nexport function PmPanelProvider({ spec, open, close, appName, locale, onFull, children }: { readonly spec?: ImproveProp | undefined; readonly open: boolean; readonly close: () => void; readonly appName?: string | undefined; readonly locale?: string | undefined; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined; readonly children: ReactNode }) {\n const resolved = useImproveAgent(spec, open, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const text = pmTextFor(locale);\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, retry: resolved.retry, text } : null, [resolved.spec, resolved.problem, resolved.retry, spec, open, close, onFull, text]);\n return <PmContext.Provider value={value}><AppNameContext.Provider value={appName ?? ''}>{children}</AppNameContext.Provider></PmContext.Provider>;\n}\n\n/** Desktop: beside the pointable app. Phone: full-screen chat, or the in-flow strip while pointing. */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const bound = useContext(PmContext);\n const [page, setPage] = useState<HTMLDivElement | null>(null);\n const binding = bound && 'spec' in bound ? bound : null, pending = bound && !('spec' in bound) ? bound : null;\n const controller = binding?.spec.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n const [pointing, setPointing] = useState(false);\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:flex-row\">\n <div ref={setPage} data-pm-page=\"\" data-pm-screen={screen} className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n {bound && inFrame() && <SiblingPoint page={page} screen={screen ?? ''} bound={bound} />}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={setPointing} />}\n {pending?.open && <PmConnecting text={pending.text} problem={pending.problem} retry={pending.retry} close={pending.close} className=\"absolute inset-0 z-40 md:static md:w-[26rem] md:shrink-0\" />}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !pointing)} className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n\nconst inFrame = () => typeof window !== 'undefined' && window.parent !== window;\n\n/** This page is framed beside a standalone PM: pointing started there runs here, and this frame's own PM steps aside for it. */\nfunction SiblingPoint({ page, screen, bound }: { readonly page: HTMLElement | null; readonly screen: string; readonly bound: Binding | Pending }) {\n const text = 'spec' in bound ? pmTextFor(bound.spec.chatProps?.locale) : bound.text;\n useSiblingPointing({ app: useContext(AppNameContext), page, screen, policy: ('spec' in bound ? bound.spec.policy : undefined) ?? defaultPolicy, text, onStart: bound.close });\n return null;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (pointing: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const returning = useRef(pm.returnToComposer);\n useLayoutEffect(() => { returning.current = pm.returnToComposer; });\n const collapse = useCallback(() => returning.current(), []);\n useLayoutEffect(() => { report(strip); }, [strip, report]);\n const onFull = binding.onFull;\n useEffect(() => { onFull?.(strip, collapse); }, [strip, collapse, onFull]);\n useEffect(() => () => { onFull?.(false, collapse); }, [onFull, collapse]);\n return <section hidden={!binding.open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={strip ? pm.state.mode : 'chat'}\n className={cn('min-h-0 min-w-0 bg-background md:relative md:w-[26rem] md:shrink-0', binding.open && 'flex flex-col', strip ? 'shrink-0' : 'absolute inset-0 z-40')}>\n <PmWorkspace spec={binding.spec} pm={pm} page={page} preset=\"touch\" open={binding.open} close={binding.close} />\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0 z-50 hidden w-px md:block\" />\n </section>;\n}\n\nconst COARSE = '(pointer: coarse)', WIDE = '(min-width: 768px)';\nconst fine = () => !matchMedia(COARSE).matches && matchMedia(WIDE).matches;\nfunction useFinePointer() {\n return useSyncExternalStore(listener => {\n const queries = [matchMedia(COARSE), matchMedia(WIDE)];\n for (const query of queries) query.addEventListener('change', listener);\n return () => { for (const query of queries) query.removeEventListener('change', listener); };\n }, fine, () => false);\n}\n\nfunction PmConnecting({ text, problem, retry, close, className }: { readonly text: PmText; readonly problem: string | undefined; readonly retry: () => void; readonly close?: (() => void) | undefined; readonly className: string }) {\n return <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className={cn('flex min-h-0 flex-col gap-3 bg-background p-4', className)}>\n <h2 className=\"m-0 text-sm\">{text.title}</h2>\n <p role={problem ? 'alert' : 'status'} className=\"m-0 text-sm text-muted-foreground\">{problem ? text.connectionFailed : text.connecting}</p>\n {problem && <Button variant=\"outline\" onClick={retry} data-testid=\"pm-connect-retry\">{text.retry}</Button>}\n {close && <Button variant=\"ghost\" data-testid=\"pm-close\" onClick={close}>{text.close}</Button>}\n </section>;\n}\n\nexport interface PmChatProps {\n /** One line, `{ agent: 'pm' }` (the kit opens the app's PM door), or a host `ImproveSpec`. */\n readonly spec: ImproveProp;\n /** The page this chat points at when it is in the same document. Without it, pointing needs `siblingApp`. */\n readonly page?: HTMLElement | null;\n /** The app's name: this PM sits in a frame beside the app's frame, and pointing runs there. */\n readonly siblingApp?: string;\n readonly screen?: string;\n readonly preset?: PmPreset;\n readonly appName?: string;\n readonly locale?: string;\n readonly close?: (() => void) | undefined;\n /** The bar's title, instead of the app's name (a host that already shows it). */\n readonly title?: string | undefined;\n}\n\n/** The PM, plugged anywhere: the regular chat filling its container. What the PM presents sits beside it when the container is wide enough, otherwise replaces it with Back. */\nexport function PmChat({ spec, appName, locale, close, ...rest }: PmChatProps) {\n const resolved = useImproveAgent(spec, true, { ...(appName ? { appName } : {}), ...(locale ? { locale } : {}) });\n const controller = resolved.spec?.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n if (!resolved.spec) return <PmConnecting text={pmTextFor(locale)} problem={resolved.problem} retry={resolved.retry} close={close} className=\"flex-1\" />;\n return <PmChatSession key={instance.sequence} spec={resolved.spec} close={close} {...rest} />;\n}\n\nfunction PmChatSession({ spec, page = null, siblingApp, screen, preset = 'desktop', close, title }: Omit<PmChatProps, 'spec' | 'appName' | 'locale'> & { readonly spec: ImproveSpec }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' && siblingApp === undefined, sibling: siblingApp });\n return <div data-testid=\"pm-chat\" data-mode={pm.state.mode} className=\"flex min-h-0 min-w-0 flex-1 flex-col\"><PmWorkspace spec={spec} page={page} pm={pm} preset={preset} open close={close} beside={siblingApp !== undefined} title={title} /></div>;\n}\n\n/** `?pm=standalone`: only the PM, full frame, for a host's column beside the app's frame. Same origin and session as the app, no route of its own. */\nexport function PmStandalone({ spec, appName, locale, hostOrigins = PM_HOST_ORIGINS }: { readonly spec: ImproveProp; readonly appName: string; readonly locale?: string | undefined; readonly hostOrigins?: readonly string[] | undefined }) {\n usePmHostTheme(hostOrigins);\n return <main data-testid=\"pm-standalone\" className=\"app-shell flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground\">\n <PmChat spec={spec} appName={appName} siblingApp={appName} preset=\"touch\" title={window.parent === window ? undefined : 'PM'} {...(locale ? { locale } : {})} />\n </main>;\n}\n\ninterface PmPanelConfig { readonly spec: ImproveProp; readonly hostOrigins?: readonly string[] | undefined }\nfunction PmShellProvider({ config, ...rest }: { readonly config: unknown; readonly open: boolean; readonly close: () => void; readonly appName?: string | undefined; readonly locale?: string | undefined; readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined; readonly children: ReactNode }) {\n return <PmPanelProvider spec={(config as PmPanelConfig).spec} {...rest} />;\n}\nfunction PmShellPage({ config, appName, locale }: { readonly config: unknown; readonly appName: string; readonly locale?: string | undefined }) {\n const { spec, hostOrigins } = config as PmPanelConfig;\n return <PmStandalone spec={spec} appName={appName} locale={locale} hostOrigins={hostOrigins} />;\n}\nconst PM_PAGE = { matches: () => new URLSearchParams(window.location.search).get('pm') === 'standalone', Page: PmShellPage };\n\n/** The product manager in `AppShell`'s `panel`: « Améliorer l'app » in the account menu, the PM panel and the point layer; `?pm=standalone` shows only the PM. One line: `pmPanel({ agent: 'pm' })` (the kit opens the app's PM door), or a host `ImproveSpec`. `hostOrigins`: the parent origins that may give the standalone PM their tokens (default: `PM_HOST_ORIGINS`, Studio). */\nexport function pmPanel(spec: ImproveProp, options?: { readonly hostOrigins?: readonly string[] | undefined }) {\n const host = isImproveAgent(spec) ? undefined : spec;\n return { config: { spec, hostOrigins: options?.hostOrigins } satisfies PmPanelConfig, Provider: PmShellProvider, Layout: ImproveLayout, page: PM_PAGE, locale: host?.chatProps?.locale, identity: host?.controller };\n}\n\n/** Wiring only: the regular agent page, with the existing point composer and a host Tasks panel. */\nfunction PmWorkspace({ spec, pm, page, preset, open, close, beside = false, title }: { readonly title?: string | undefined; readonly spec: ImproveSpec; readonly pm: ReturnType<typeof usePmPanel>; readonly page: HTMLElement | null; readonly preset: PmPreset; readonly open: boolean; readonly close?: (() => void) | undefined; readonly beside?: boolean }) {\n const { text } = pm;\n const files = spec.resources ? { resources: spec.resources } : { resources: undefined };\n // On a phone the chat gives the page its screen while pointing. A PM beside the app's frame covers nothing.\n const phone = usePhone() && !beside;\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const root = useRef<HTMLDivElement | null>(null);\n const kinds = useMemo(() => spec.artifactKinds ?? defaultKinds({ locale: text.locale }), [spec.artifactKinds, text.locale]);\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n const seenKey = `boring.pm.ships.${JSON.stringify(pm.snapshot.identity)}`;\n const [seen, setSeen] = useState<readonly string[]>(() => { try { const value: unknown = JSON.parse(localStorage.getItem(seenKey) ?? '[]'); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } catch { return []; } });\n const ships = spec.tasks ? selectTasks(spec.tasks.tasks, { filter: 'mine', currentUser: spec.tasks.currentUser }).filter(task => !task.technical && requestOutcome(task) === 'live').map(task => `${task.id}:${task.updated_at ?? ''}`) : [];\n const tasksLabel = text.locale === 'fr' ? 'Tâches' : 'Tasks';\n // Beside the app in a host's frame, the host owns the one way to the tasks (Julien, 2026-10-31: no second icon).\n const barPanels: readonly WorkspaceBarPanel[] = beside ? [] : [{ id: 'tasks', label: tasksLabel, icon: ListTodoIcon, dot: spec.tasks?.unread ?? ships.some(ship => !seen.includes(ship)), onOpenChange: spec.tasks?.onOpenChange, onOpen: () => {\n refreshTasks?.(); setSeen(ships); try { localStorage.setItem(seenKey, JSON.stringify(ships)); } catch { /* viewer storage is optional */ }\n }, content: () => <div data-testid=\"pm-task-board-seam\" className=\"flex min-h-0 flex-1 flex-col\">{spec.tasks ? <>{spec.tasks.header}<PmRequests seam={spec.tasks} text={text} /></> : <p role=\"status\" className=\"p-4 text-sm text-muted-foreground\">{text.connectionFailed}</p>}</div> }];\n useEffect(() => {\n if (!pm.focusVersion || !open || strip) return;\n const input = root.current?.querySelector<HTMLElement>('[data-testid=composer-input]');\n input?.focus({ preventScroll: true });\n if (input instanceof HTMLTextAreaElement) input.setSelectionRange(input.value.length, input.value.length);\n }, [pm.focusVersion, open, strip, spec.controller]);\n const pointProps = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n canPoint: pm.canPoint, onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">{text.locale === 'fr' ? 'Transcription…' : 'Transcribing…'}</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n return <ChatTextProvider value={chatTextFor(text.locale)}><div ref={root} className=\"flex min-h-0 flex-1 flex-col\">\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n <div hidden={strip && phone} className={cn('min-h-0 flex-1 flex-col', strip && phone ? 'hidden' : 'flex')}>\n <AgentWorkspace layout=\"embedded\" title={title ?? spec.appName ?? spec.label ?? text.title} locale={text.locale} controller={spec.controller}\n conversationId={spec.conversationId ?? String(pm.snapshot.conversationId)} conversations={spec.conversations ?? spec.chatProps?.conversations}\n {...files} artifactKinds={kinds} interactive={PM_INTERACTIVE} autoOpen={Boolean(spec.resources)} barPanels={barPanels} barOptions={{ panelsFirst: true }}\n controls={close && <Button variant=\"ghost\" size=\"icon\" data-testid=\"pm-close\" aria-label={text.close} title={text.close} onClick={close}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n chat={{ ...spec.chatProps, ...(mentions ? { mentions } : {}),\n labels: { ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send },\n onFiles: spec.chatProps?.onFiles ?? imageFiles, sendContext: pm.sendContext, replyError: spec.chatProps?.replyError ?? (() => text.failed),\n renderEntry: entry => pointMessage(entry, mentions, { preset, text }) ?? spec.chatProps?.renderEntry?.(entry),\n replyFooter: reply => { const entry = pm.snapshot.view?.entries.find(entry => String(entry.id) === reply.entryId); return <>{spec.chatProps?.replyFooter?.(reply)}{entry && spec.tasks?.renderRequest?.({ entry, edit })}</>; },\n renderComposer: composer => strip && phone ? null : strip ? <>{beside && pm.state.chips.length > 0 && <div data-testid=\"pm-beside-chips\" className=\"flex flex-wrap gap-1.5 pb-2\">{pm.state.chips.map(chip => <PointChipView key={chip.id} chip={chip} preset={preset} labels={text} onRemove={() => pm.remove(chip.id)} />)}</div>}<PmComposer variant=\"strip\" {...pointProps} /></> : <PmComposer {...pointProps} composer={pm.answerComposer({ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) })} transcript={transcript} /> }} />\n </div>\n {strip && phone && <PmComposer variant=\"strip\" {...pointProps} />}\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\n/** « Mes demandes »: the one board, the viewer's own tasks. The panel's width stacks it; the detail is where a request is answered or withdrawn. */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [selected, setSelected] = useState<string | null>(null);\n const answer = (task: TaskRecord, _question: unknown, value: string) => seam.answer?.(task, value);\n return <TaskBoard tasks={seam.tasks} filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests} locale={text.locale} selectedTask={selected}\n onSelectedTaskChange={id => { setSelected(id); const task = seam.tasks.find(each => each.id === id); if (task) seam.onOpen(task); }}\n actions={task => requestOutcome(task) ? undefined : seam.cardActions?.(task)} onAnswer={answer} onOther={answer} className=\"min-h-0 flex-1\" />;\n}\n",
33
33
  "type": "registry:component",
34
34
  "target": "components/pm-panel/pm-panel.tsx"
35
35
  },
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "path": "registry/pm-panel/pm-host-theme.ts",
68
- "content": "'use client';\n\nimport { useEffect } from 'react';\n\n/** The parents whose style a standalone PM takes without any app change. A preview host is added with `AppShell`'s `pmHostOrigins`. */\nexport const PM_HOST_ORIGINS: readonly string[] = ['https://studio.senecaapp.ai'];\n\nconst TOKEN = /^--(?:background|foreground|card(?:-foreground)?|popover(?:-foreground)?|primary(?:-foreground)?|secondary(?:-foreground)?|muted(?:-foreground)?|accent(?:-foreground)?|destructive|border|input|ring|radius|font-sans|pm-[a-z0-9-]+)$/;\nconst VALUE = /^[\\w\\s#%.,()/+'\"-]{1,160}$/;\nconst FORBIDDEN = /url|image|expression|attr|src|\\/\\*/i;\n\n/** A host's token set, kept only where the name is a known theme token and the value cannot load, run or escape anything. */\nexport function acceptedTokens(tokens: unknown): readonly (readonly [string, string])[] {\n if (!tokens || typeof tokens !== 'object') return [];\n return Object.entries(tokens).filter((entry): entry is [string, string] => {\n const [name, value] = entry;\n return TOKEN.test(name) && typeof value === 'string' && VALUE.test(value) && !FORBIDDEN.test(value);\n });\n}\n\nfunction applyTheme(theme: unknown) {\n if (theme !== 'light' && theme !== 'dark') return;\n document.documentElement.classList.toggle('dark', theme === 'dark');\n document.documentElement.style.colorScheme = theme;\n}\n\n/** `?theme=light|dark`, then `{ boring: 'pm-theme', theme?, tokens? }` at any time from an allowlisted parent. Without either the app's tokens stand. */\nexport function usePmHostTheme(origins: readonly string[]): void {\n const key = origins.join(' ');\n useEffect(() => {\n applyTheme(new URLSearchParams(window.location.search).get('theme'));\n if (window.parent === window) return;\n const allowed = key.split(' ');\n const listen = (event: MessageEvent) => {\n const data = event.data as { boring?: unknown; theme?: unknown; tokens?: unknown } | null;\n if (event.source !== window.parent || !allowed.includes(event.origin) || data?.boring !== 'pm-theme') return;\n applyTheme(data.theme);\n for (const [name, value] of acceptedTokens(data.tokens)) document.documentElement.style.setProperty(name, value);\n };\n window.addEventListener('message', listen);\n // The parent may be waiting for this frame before it sends its look.\n const parent = window.location.ancestorOrigins?.[0] ?? (document.referrer ? new URL(document.referrer).origin : undefined);\n if (parent && allowed.includes(parent)) window.parent.postMessage({ boring: 'pm-ready' }, parent);\n return () => window.removeEventListener('message', listen);\n }, [key]);\n}\n",
68
+ "content": "'use client';\n\nimport { useEffect } from 'react';\n\n/** The parents whose style a standalone PM takes without any app change. A preview host is added with `pmPanel`'s `hostOrigins`. */\nexport const PM_HOST_ORIGINS: readonly string[] = ['https://studio.senecaapp.ai'];\n\nconst TOKEN = /^--(?:background|foreground|card(?:-foreground)?|popover(?:-foreground)?|primary(?:-foreground)?|secondary(?:-foreground)?|muted(?:-foreground)?|accent(?:-foreground)?|destructive|border|input|ring|radius|font-sans|pm-[a-z0-9-]+)$/;\nconst VALUE = /^[\\w\\s#%.,()/+'\"-]{1,160}$/;\nconst FORBIDDEN = /url|image|expression|attr|src|\\/\\*/i;\n\n/** A host's token set, kept only where the name is a known theme token and the value cannot load, run or escape anything. */\nexport function acceptedTokens(tokens: unknown): readonly (readonly [string, string])[] {\n if (!tokens || typeof tokens !== 'object') return [];\n return Object.entries(tokens).filter((entry): entry is [string, string] => {\n const [name, value] = entry;\n return TOKEN.test(name) && typeof value === 'string' && VALUE.test(value) && !FORBIDDEN.test(value);\n });\n}\n\nfunction applyTheme(theme: unknown) {\n if (theme !== 'light' && theme !== 'dark') return;\n document.documentElement.classList.toggle('dark', theme === 'dark');\n document.documentElement.style.colorScheme = theme;\n}\n\n/** `?theme=light|dark`, then `{ boring: 'pm-theme', theme?, tokens? }` at any time from an allowlisted parent. Without either the app's tokens stand. */\nexport function usePmHostTheme(origins: readonly string[]): void {\n const key = origins.join(' ');\n useEffect(() => {\n applyTheme(new URLSearchParams(window.location.search).get('theme'));\n if (window.parent === window) return;\n const allowed = key.split(' ');\n const listen = (event: MessageEvent) => {\n const data = event.data as { boring?: unknown; theme?: unknown; tokens?: unknown } | null;\n if (event.source !== window.parent || !allowed.includes(event.origin) || data?.boring !== 'pm-theme') return;\n applyTheme(data.theme);\n for (const [name, value] of acceptedTokens(data.tokens)) document.documentElement.style.setProperty(name, value);\n };\n window.addEventListener('message', listen);\n // The parent may be waiting for this frame before it sends its look.\n const parent = window.location.ancestorOrigins?.[0] ?? (document.referrer ? new URL(document.referrer).origin : undefined);\n if (parent && allowed.includes(parent)) window.parent.postMessage({ boring: 'pm-ready' }, parent);\n return () => window.removeEventListener('message', listen);\n }, [key]);\n}\n",
69
69
  "type": "registry:hook",
70
70
  "target": "components/pm-panel/pm-host-theme.ts"
71
71
  },
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {