@hachej/boring-ui-kit 0.4.36 → 0.4.38

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 (58) hide show
  1. package/dist/experience-document-viewer.js +1 -1
  2. package/dist/file-tree-view.d.ts +0 -1
  3. package/dist/file-tree-view.js +3 -5
  4. package/dist/file-tree.js +2 -2
  5. package/dist/html-viewer.js +1 -1
  6. package/dist/shared-chat/room.js +23 -7
  7. package/dist/shared-chat/source.d.ts +3 -0
  8. package/dist/text-buffer.js +1 -1
  9. package/package.json +2 -2
  10. package/r/agent-target.json +4 -4
  11. package/r/app-shell.json +10 -10
  12. package/r/board-viewer.json +6 -6
  13. package/r/button.json +1 -1
  14. package/r/dock.json +1 -1
  15. package/r/documents.json +12 -12
  16. package/r/feedback-preview.json +1 -1
  17. package/r/feedback.json +6 -6
  18. package/r/file-tree.json +4 -4
  19. package/r/highlights.json +3 -3
  20. package/r/html-viewer.json +3 -3
  21. package/r/looks-viewer.json +2 -2
  22. package/r/maquette-viewer.json +1 -1
  23. package/r/markdown-editor.json +3 -3
  24. package/r/mockup-viewer.json +1 -1
  25. package/r/pi-ambient.json +8 -8
  26. package/r/pi-app-kinds.json +10 -10
  27. package/r/pi-app.json +10 -10
  28. package/r/pi-artifacts.json +2 -2
  29. package/r/pi-chat-approval.json +2 -2
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +2 -2
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +2 -2
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +3 -3
  39. package/r/pi-chat.json +5 -5
  40. package/r/pi-workspace.json +3 -3
  41. package/r/placed-agent.json +4 -4
  42. package/r/pm-panel.json +16 -16
  43. package/r/provider-setup.json +1 -1
  44. package/r/registry.json +183 -183
  45. package/r/requests-board.json +1 -1
  46. package/r/shared-chat.json +9 -9
  47. package/r/task-board.json +2 -2
  48. package/r/task-list-viewer.json +2 -2
  49. package/r/task-mentions.json +4 -4
  50. package/r/version-history.json +2 -2
  51. package/r/viewer-code.json +2 -2
  52. package/r/viewer-frame.json +4 -4
  53. package/r/viewer-html.json +4 -4
  54. package/r/viewer-image.json +2 -2
  55. package/r/viewer-markdown.json +5 -5
  56. package/r/viewer-pdf.json +3 -3
  57. package/r/viewers.json +6 -6
  58. package/r/windows.json +1 -1
package/r/pm-panel.json CHANGED
@@ -4,30 +4,30 @@
4
4
  "title": "Shared PM composer and in-app panel",
5
5
  "description": "« Améliorer l'app »: one PmComposer (touch and desktop presets, FR/EN strings) with « Montrer », the point strip, numbered pins and timestamped chips; AppShell improve composes the in-flow sheet, strip and the full panel, and PmChat gives Studio the same conversation. Pins and chips come from the public feedback picker; the request card and « Mes demandes » render through StudioTaskBoardSeam (D-417).",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.36",
8
- "@hachej/boring-feedback@^0.4.36",
9
- "@hachej/boring-files@^0.4.36",
7
+ "@hachej/boring-ui-kit@^0.4.38",
8
+ "@hachej/boring-feedback@^0.4.38",
9
+ "@hachej/boring-files@^0.4.38",
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.36/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/pi-chat-markdown.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/pi-chat-plugins.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/pi-chat-voice.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/feedback.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/task-board.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/task-mentions.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/utils.json",
24
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/pi-artifacts.json",
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.36/r/viewer-frame.json"
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-markdown.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-plugins.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-voice.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/feedback.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/task-board.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/task-mentions.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
24
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-artifacts.json",
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json"
26
26
  ],
27
27
  "files": [
28
28
  {
29
29
  "path": "registry/pm-panel/pm-panel.tsx",
30
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';\nimport { ChevronDownIcon, ChevronUpIcon, XIcon } from 'lucide-react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { cn } from '../utils/utils';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { PmMockupProvider, PmMockupView, pmArtifacts } from './pm-mockups';\nimport type { ArtifactDescriptor } from '@hachej/boring-ui-kit/chat-view';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard, TaskRequestCard, taskTokens } from '../task-board/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 type { MentionsConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\n\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 { PmArtifactCard, PmMockupView } from './pm-mockups';\nexport type { PmReadArtifact } from './pm-mockups';\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 title: string }\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\nconst PmContext = createContext<Binding | Pending | null>(null);\n\n/**\n * `spec` is either the host's own `ImproveSpec` or the one line `{ agent: 'pm' }`, which the kit resolves to that agent's conversation\n * through the app's door on the first open (`useImproveAgent`). `onFull` reports the full panel (C7) so the shell gives it its own Back step.\n */\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 title = pmTextFor(locale).title;\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, title } : null, [resolved.spec, resolved.problem, spec, open, close, onFull, title]);\n return <PmContext.Provider value={value}>{children}</PmContext.Provider>;\n}\n\n/**\n * The app's content and tab bar remain in flow. The sheet (C2, C6) and the strip (C3–C5) take layout space, so the page shrinks and stays\n * scrollable and tappable; only the full panel (C7) overlays the page, from 40px below the top.\n */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const binding = useContext(PmContext);\n return <BoundLayout binding={binding} navigation={navigation} screen={screen}>{children}</BoundLayout>;\n}\n\nfunction BoundLayout({ binding: bound, children, navigation, screen }: { readonly binding: Binding | Pending | null; readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen: string | undefined }) {\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 [placement, setPlacement] = useState({ controller, mode: 'sheet', show: false });\n const report = useCallback((controller: ImproveSpec['controller'], mode: string, show: boolean) => setPlacement(current => current.controller === controller && current.mode === mode && current.show === show ? current : { controller, mode, show }), []);\n const current = placement.controller === controller ? placement : { mode: 'sheet', show: false };\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n const strip = current.mode === 'point' || current.mode === 'navigate';\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 {/* Mounted on the first open, then kept: the picks, pins and recording survive × (spec 2, draft persistence). */}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={report} />}\n {pending?.open && <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className=\"flex shrink-0 flex-col gap-1.5 rounded-t-pm-sheet bg-background px-3 pt-2 pb-6 shadow-pm-sheet\">\n <div className=\"flex items-center\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{pending.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Fermer\" data-testid=\"pm-close\" onClick={pending.close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button></div>\n <p role={pending.problem ? 'alert' : 'status'} className=\"m-0 px-1 text-sm text-muted-foreground\">{pending.problem ?? 'Connexion…'}</p>\n </section>}\n {/* The tab bar shows under the strip and works normally; the sheet sits at the bottom instead of it. */}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !strip)} className=\"shrink-0\">{navigation}</div>\n </div>;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (controller: ImproveSpec['controller'], mode: string, show: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const { mode, show } = pm.state;\n useLayoutEffect(() => { report(binding.spec.controller, mode, show); }, [binding.spec.controller, mode, show, report]);\n return <>\n <Panel binding={binding} pm={pm} strip={mode === 'point' || mode === 'navigate'} full={mode === 'full'} />\n {pm.comment && page && <ChatTextProvider value={chatTextFor(binding.spec.chatProps?.locale ?? 'fr')}><PointComment comment={pm.comment} frame={page} text={pm.text} /></ChatTextProvider>}\n </>;\n}\n\n/** The desktop preset's click to comment (spec 1 §3b) follows the pointer: web width without a touch screen as the main pointer, never a phone. */\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\n/** The grabber: drag up opens the full panel, drag down closes (the draft stays). */\nfunction Grabber({ wide, onUp, onDown }: { readonly wide: boolean; readonly onUp?: (() => void) | undefined; readonly onDown: () => void }) {\n const start = useRef<number | undefined>(undefined);\n const end = (event: ReactPointerEvent) => {\n if (start.current === undefined) return;\n const moved = event.clientY - start.current; start.current = undefined;\n if (moved < -24) onUp?.(); else if (moved > 24) onDown();\n };\n return <div data-testid=\"pm-grabber\" aria-hidden=\"true\" className=\"flex shrink-0 cursor-grab touch-none justify-center pb-1\" onPointerDown={event => { start.current = event.clientY; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerUp={end} onPointerCancel={() => { start.current = undefined; }}>\n <div className={cn('h-1 rounded-pm-round', wide ? 'w-[100px] bg-border' : 'w-10 bg-pm-line')} />\n </div>;\n}\n\nfunction assistantText(entry: EntryRecord): string | undefined {\n const messages = entry.model?.filter(message => message.role === 'assistant');\n if (!messages?.length) return undefined;\n const text = messages.map(message => typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('')).join('\\n').trim();\n return text || undefined;\n}\nconst failedRun = (entry: EntryRecord) => entry.model?.filter(message => message.role === 'assistant').at(-1)?.stopReason === 'error';\n\nconst callsTools = (entry: EntryRecord) => entry.model?.some(message => message.role === 'assistant' && typeof message.content !== 'string' && message.content.some(part => part.type === 'toolCall')) === true;\n\n/**\n * The PM thread's entries: sent points with their chips, the PM's reply (Markdown; plain text on touch, a bubble on desktop) and its\n * request card. `member` is the app panel's member thread (C7): a round that calls tools keeps the chat's own rows (its tool trace, mockup\n * and question cards), and a run that ends in error without a reply shows one muted line.\n */\nexport function pmEntries({ preset, text, mentions, renderEntry, renderRequest, edit, member = false }: {\n readonly preset: PmPreset; readonly text: PmText; readonly mentions: MentionsConfig | undefined;\n readonly renderEntry?: ((entry: EntryRecord) => ReturnType<NonNullable<PiChatProps['renderEntry']>>) | undefined;\n readonly renderRequest?: StudioTaskBoardSeam['renderRequest']; readonly edit: (text: string) => void; readonly member?: boolean;\n}) {\n return (entry: EntryRecord) => {\n const sent = pointMessage(entry, mentions, { preset, text });\n if (sent) return sent;\n const own = renderEntry?.(entry);\n if (own) return own;\n if (member && callsTools(entry) && !failedRun(entry)) return undefined;\n const reply = assistantText(entry);\n if (reply === undefined) return member && failedRun(entry) ? { inline: true, content: <p data-testid=\"pm-failed\" className=\"m-0 text-sm text-muted-foreground\">{text.failed}</p> } : undefined;\n // Headings stay at body size inside the panel.\n return { inline: true, content: <div data-testid=\"pm-reply\" data-role=\"assistant\" className=\"flex flex-col items-start gap-3.5\">\n <div className={cn('rounded-pm-bubble rounded-bl-pm-tail bg-pm-reply [&_:is(h1,h2,h3,h4,h5,h6)]:text-[length:inherit]', preset === 'touch' ? 'text-[15px] leading-[1.45]' : 'max-w-[88%] px-3 py-[9px] text-sm leading-[1.45]')}><Markdown text={reply} /></div>\n {renderRequest?.({ entry, edit })}\n </div> };\n };\n}\n\n/**\n * The PM conversation for a column (Studio, desktop preset) or any host surface: the thread and the one composer, which the strip\n * replaces while pointing. `page` is the element the point layer picks in (Studio: the app's device frame).\n */\nexport function PmChat({ spec, page, screen, preset = 'desktop' }: { readonly spec: ImproveSpec; readonly page: HTMLElement | null; readonly screen?: string; readonly preset?: PmPreset }) {\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' });\n const { text } = pm;\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const props = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n return <ChatTextProvider value={chatTextFor(spec.chatProps?.locale ?? 'en')}><div data-testid=\"pm-chat\" data-mode={pm.state.mode} data-preset={preset} className={cn('flex min-h-0 flex-1 flex-col',\n '[&_[data-boring=pi-chat]]:bg-transparent [&_[data-testid=empty-state]]:hidden [&_[data-testid=transcript-scroll]>div:last-child]:flex [&_[data-testid=transcript-scroll]>div:last-child]:min-h-full [&_[data-testid=transcript-scroll]>div:last-child]:flex-col [&_[data-testid=transcript-scroll]>div:last-child]:justify-end [&_[data-boring=pi-chat]>footer]:px-3.5 [&_[data-boring=pi-chat]>footer]:pt-0 [&_[data-boring=pi-chat]>footer]:pb-3.5 [&_[data-testid=transcript]]:gap-2.5 [&_[data-testid=transcript-scroll]>div:last-child]:px-[18px] [&_[data-testid=transcript-scroll]>div:last-child]:py-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto')}>\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 <PiChat {...withChatPlugins({ ...spec.chatProps, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'en'} header={false} emptyLayout=\"docked\"\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={pm.sendContext} renderEntry={pmEntries({ preset, text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit })}\n renderComposer={composer => strip ? <PmComposer variant=\"strip\" {...props} /> : <PmComposer {...props} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' }} />} />\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></ChatTextProvider>;\n}\n\n/**\n * « Mes demandes » (decided 2026-10-29): open requests on top, as the compact board with their actions; finished ones (`done`, `closed`)\n * below, under « Terminées (n) », collapsed by default and absent when there are none, each with one muted label and no action.\n */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [expanded, setExpanded] = useState(false);\n const open = seam.tasks.filter(task => requestOutcome(task) === undefined);\n const finished = selectTasks(seam.tasks.filter(task => requestOutcome(task) !== undefined), { filter: 'mine', currentUser: seam.currentUser });\n const label = { live: text.outcomeLive, withdrawn: text.outcomeWithdrawn, closed: text.outcomeClosed } as const;\n return <div className=\"flex flex-col gap-4 p-4\">\n {(open.length > 0 || finished.length === 0) && <TaskBoard tasks={open} variant=\"compact\" filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests}\n onOpen={seam.onOpen} cardActions={seam.cardActions} locale={text.locale} viewerAction=\"confirmation\" />}\n {finished.length > 0 && <section data-testid=\"pm-finished\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">\n <Button variant=\"ghost\" data-testid=\"pm-finished-toggle\" aria-expanded={expanded} aria-controls=\"pm-finished-list\" onClick={() => setExpanded(!expanded)}\n className=\"min-h-11 justify-between px-1 text-sm font-semibold text-muted-foreground\">\n {text.finished(finished.length)}<ChevronDownIcon aria-hidden=\"true\" className={cn('size-4 transition-transform', expanded && 'rotate-180')} />\n </Button>\n {expanded && <div id=\"pm-finished-list\" role=\"list\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">{finished.map(task => {\n const outcome = requestOutcome(task)!;\n return <TaskRequestCard key={task.id} task={task} variant=\"compact\" locale={text.locale}\n pill={<span className={cn('contents', taskTokens.pill)}><Badge variant={outcome === 'live' ? 'secondary' : 'outline'} data-task-outcome={outcome}>{label[outcome]}</Badge></span>} />;\n })}</div>}\n </section>}\n </div>;\n}\n\nfunction Panel({ binding, pm, strip, full }: { readonly binding: Binding; readonly pm: ReturnType<typeof usePmPanel>; readonly strip: boolean; readonly full: boolean }) {\n const { spec, open, close: closeLayer } = binding;\n const [requests, setRequestsState] = useState(false);\n // An opened mockup (`present`): full height in the sheet, under « ‹ Retour ».\n const [mockup, setMockup] = useState<ArtifactDescriptor>();\n // The task source is read again when « Mes demandes » opens and when the panel opens (a 403 or 404 is « unavailable for now »).\n const setRequests = (next: boolean) => { if (next && !requests) spec.tasks?.refresh?.(); setRequestsState(next); };\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n // Closing leaves « Mes demandes » and the mockup: the next full panel opens on the thread.\n const close = () => { setRequestsState(false); setMockup(undefined); closeLayer(); };\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, pm.text);\n const sheet = useRef<HTMLDivElement | null>(null);\n const submit = pm.sendContext;\n const { dispatch, text } = pm;\n // Plain-text sends have no context attachment, but still expand on observed native admission.\n // A comment sent from the popover keeps the strip (spec 1 §3b): only the composer's own sends open C7.\n const admitted = pm.snapshot.send.kind === 'admitted' && !pm.quietSend(pm.snapshot.send.attempt.draft.text) ? pm.snapshot.send.attempt.requestId : undefined;\n const previousAdmission = useRef<string | undefined>(undefined);\n // The full panel is its own Back step (full, then sheet, then closed): the shell owns the history entries.\n const { onFull } = binding;\n useEffect(() => { onFull?.(full, () => dispatch({ type: 'collapse' })); }, [full, onFull, dispatch]);\n useEffect(() => {\n if (admitted && admitted !== previousAdmission.current) dispatch({ type: 'full' });\n if (admitted) previousAdmission.current = admitted;\n }, [admitted, dispatch]);\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const focusVersion = pm.focusVersion;\n useEffect(() => {\n if (!focusVersion || !open || strip) return;\n const input = sheet.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 }, [focusVersion, open, strip, spec.controller]);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const pointProps = { preset: 'touch' as const, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const chatText = chatTextFor(spec.chatProps?.locale ?? 'fr');\n const review = !full && pm.meta !== undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">Transcription…</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n const renderEntry = pmEntries({ preset: 'touch', text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit, member: true });\n const showMockup = useCallback((artifact: ArtifactDescriptor) => { setMockup(artifact); dispatch({ type: 'full' }); }, [dispatch]);\n const artifacts = useMemo(() => spec.chatProps?.artifacts ?? pmArtifacts(showMockup, mockup), [spec.chatProps?.artifacts, showMockup, mockup]);\n const aside = full && (requests || mockup !== undefined);\n const header = full ? <>\n <div className=\"flex shrink-0 items-center gap-2.5 py-2 pr-2 pl-4\">\n <div className=\"min-w-0 flex-1\">\n <h2 data-testid=\"pm-title\" className=\"m-0 truncate text-[15px] leading-tight font-pm-title\">{text.title}</h2>\n <p className=\"m-0 truncate text-xs text-muted-foreground\">{text.subtitle(spec.appName ?? spec.label ?? '')}</p>\n </div>\n <span className=\"flex shrink-0 [&_button]:px-0 [&_button]:text-sm [&_button]:font-semibold [&_button]:text-foreground [&_button]:underline\"><Button variant=\"link\" data-testid=\"pm-requests\" disabled={!spec.tasks} onClick={() => setRequests(!requests)} className=\"min-h-11\">{text.requests}</Button></span>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-11 min-w-11\"><XIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n </div>\n <Separator />\n </> : review ? <div className=\"flex shrink-0 items-center px-1 py-0.5\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-sm font-pm-title\">{text.title}</h2>\n <span data-testid=\"pm-review-hint\" className=\"text-xs text-muted-foreground\">{text.review}</span>\n </div> : <div className=\"flex shrink-0 items-center\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{text.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.openPanel} title={text.openPanel} data-testid=\"pm-expand\" onClick={() => pm.dispatch({ type: 'full' })} className=\"min-h-9 min-w-9\"><ChevronUpIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </div>;\n return <section hidden={!open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={pm.state.mode} data-review={review || undefined}\n className={cn(open && !strip && (full ? 'absolute inset-x-0 top-10 bottom-0 z-40 flex flex-col' : 'flex max-h-[85dvh] min-h-0 shrink-0 flex-col'), strip && 'shrink-0')}>\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n <ChatTextProvider value={chatText}><PmMockupProvider read={spec.readArtifact} text={text}>\n <div hidden={!strip}><PmComposer variant=\"strip\" {...pointProps} /></div>\n <div hidden={strip} ref={sheet} data-testid=\"pm-sheet\" className={cn('min-h-0 flex-1 flex-col rounded-t-pm-sheet bg-background', strip ? 'hidden' : 'flex', full ? 'shadow-pm-panel pt-2' : 'gap-1.5 px-3 pt-2 pb-6 shadow-pm-sheet')}>\n <Grabber wide={full} onUp={full ? undefined : () => pm.dispatch({ type: 'full' })} onDown={close} />\n {mockup && full && !requests ? <PmMockupView artifact={mockup} text={text} onBack={() => setMockup(undefined)} /> : requests && full ? <>\n <div className=\"flex shrink-0 items-center py-1 pr-2 pl-2\"><Button variant=\"ghost\" data-testid=\"pm-requests-back\" onClick={() => setRequests(false)} className=\"min-h-11\">{text.back}</Button></div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto\" data-testid=\"pm-task-board-seam\">{spec.tasks && <PmRequests seam={spec.tasks} text={text} />}</div>\n </> : header}\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n {/* The member never sees a raw tool error: pi-chat's trace stays, the failed step's details and orphan error notices are hidden. */}\n <div hidden={aside} className={cn('min-h-0 flex-1 flex-col [&_[data-testid=tool-details]]:hidden [&_[data-testid=tool-error]]:hidden', aside ? 'hidden' : 'flex',\n full ? '[&_[data-boring=pi-chat]>footer]:px-4 [&_[data-boring=pi-chat]>footer]:pb-6 [&_[data-testid=transcript]]:gap-3.5 [&_[data-testid=transcript]]:text-[15px] [&_[data-testid=transcript-scroll]>div:last-child]:p-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto [&_[data-testid=question-option]]:min-h-11 [&_[data-testid=question-input]]:min-h-11 [&_[data-testid=question-submit]]:min-h-11'\n : '[&_[data-testid=transcript]]:hidden [&_[data-boring=pi-chat]>.relative]:hidden [&_[data-boring=pi-chat]>footer]:p-0')}>\n <PiChat {...withChatPlugins({ ...spec.chatProps, artifacts, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'fr'} header={false} emptyLayout=\"docked\" keyboardInset={false} replyError={spec.chatProps?.replyError ?? (() => text.failed)}\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={submit} renderEntry={renderEntry} renderComposer={composer => <PmComposer {...pointProps} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' || pm.snapshot.send.kind === 'validating' || pm.snapshot.send.kind === 'submitting', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) }} transcript={transcript} />} />\n </div>\n </div>\n </PmMockupProvider></ChatTextProvider>\n </section>;\n}\n",
30
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';\nimport { ChevronDownIcon, ChevronUpIcon, XIcon } from 'lucide-react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport { ChatTextProvider } from '../pi-chat/labels';\nimport { chatTextFor } from '../pi-chat/locale';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { cn } from '../utils/utils';\nimport { PmComposer } from './pm-composer';\nimport { PointComment } from './point-comment';\nimport { usePmPanel } from './use-pm-panel';\nimport { POINT_PREFACE, pointMessage } from './point-message';\nimport { PmMockupProvider, PmMockupView, pmArtifacts } from './pm-mockups';\nimport type { ArtifactDescriptor } from '@hachej/boring-ui-kit/chat-view';\nimport { usePmMentions } from './pm-mentions';\nimport { TaskBoard, TaskRequestCard, taskTokens } from '../task-board/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 type { MentionsConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\n\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 { PmArtifactCard, PmMockupView } from './pm-mockups';\nexport type { PmReadArtifact } from './pm-mockups';\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 title: string }\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\nconst PmContext = createContext<Binding | Pending | null>(null);\n\n/**\n * `spec` is either the host's own `ImproveSpec` or the one line `{ agent: 'pm' }`, which the kit resolves to that agent's conversation\n * through the app's door on the first open (`useImproveAgent`). `onFull` reports the full panel (C7) so the shell gives it its own Back step.\n */\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 title = pmTextFor(locale).title;\n const value = useMemo<Binding | Pending | null>(() => resolved.spec ? { spec: resolved.spec, open, close, onFull } : spec ? { open, close, problem: resolved.problem, title } : null, [resolved.spec, resolved.problem, spec, open, close, onFull, title]);\n return <PmContext.Provider value={value}>{children}</PmContext.Provider>;\n}\n\n/**\n * The app's content and tab bar remain in flow. The sheet (C2, C6) and the strip (C3–C5) take layout space, so the page shrinks and stays\n * scrollable and tappable; only the full panel (C7) overlays the page, from 40px below the top.\n */\nexport function ImproveLayout({ children, navigation, screen }: { readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen?: string }) {\n const binding = useContext(PmContext);\n return <BoundLayout binding={binding} navigation={navigation} screen={screen}>{children}</BoundLayout>;\n}\n\nfunction BoundLayout({ binding: bound, children, navigation, screen }: { readonly binding: Binding | Pending | null; readonly children: ReactNode; readonly navigation?: ReactNode; readonly screen: string | undefined }) {\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 [placement, setPlacement] = useState({ controller, mode: 'sheet', show: false });\n const report = useCallback((controller: ImproveSpec['controller'], mode: string, show: boolean) => setPlacement(current => current.controller === controller && current.mode === mode && current.show === show ? current : { controller, mode, show }), []);\n const current = placement.controller === controller ? placement : { mode: 'sheet', show: false };\n const [opened, setOpened] = useState(false);\n if (binding?.open && !opened) setOpened(true);\n const strip = current.mode === 'point' || current.mode === 'navigate';\n return <div data-pm-layout=\"\" className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\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 {/* Mounted on the first open, then kept: the picks, pins and recording survive × (spec 2, draft persistence). */}\n {binding && opened && <PanelSession key={instance.sequence} binding={binding} page={page} screen={screen} report={report} />}\n {pending?.open && <section data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode=\"connecting\" className=\"flex shrink-0 flex-col gap-1.5 rounded-t-pm-sheet bg-background px-3 pt-2 pb-6 shadow-pm-sheet\">\n <div className=\"flex items-center\"><h2 className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{pending.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Fermer\" data-testid=\"pm-close\" onClick={pending.close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button></div>\n <p role={pending.problem ? 'alert' : 'status'} className=\"m-0 px-1 text-sm text-muted-foreground\">{pending.problem ?? 'Connexion…'}</p>\n </section>}\n {/* The tab bar shows under the strip and works normally; the sheet sits at the bottom instead of it. */}\n <div data-pm-navigation=\"\" hidden={Boolean(bound?.open && !strip)} className=\"shrink-0\">{navigation}</div>\n </div>;\n}\n\nfunction PanelSession({ binding, page, screen, report }: { readonly binding: Binding; readonly page: HTMLElement | null; readonly screen: string | undefined; readonly report: (controller: ImproveSpec['controller'], mode: string, show: boolean) => void }) {\n const pm = usePmPanel(binding.spec, binding.open, page, screen, { comment: useFinePointer() });\n const { mode, show } = pm.state;\n useLayoutEffect(() => { report(binding.spec.controller, mode, show); }, [binding.spec.controller, mode, show, report]);\n return <>\n <Panel binding={binding} pm={pm} strip={mode === 'point' || mode === 'navigate'} full={mode === 'full'} />\n {pm.comment && page && <ChatTextProvider value={chatTextFor(binding.spec.chatProps?.locale ?? 'fr')}><PointComment comment={pm.comment} frame={page} text={pm.text} /></ChatTextProvider>}\n </>;\n}\n\n/** The desktop preset's click to comment (spec 1 §3b) follows the pointer: web width without a touch screen as the main pointer, never a phone. */\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\n/** The grabber: drag up opens the full panel, drag down closes (the draft stays). */\nfunction Grabber({ wide, onUp, onDown }: { readonly wide: boolean; readonly onUp?: (() => void) | undefined; readonly onDown: () => void }) {\n const start = useRef<number | undefined>(undefined);\n const end = (event: ReactPointerEvent) => {\n if (start.current === undefined) return;\n const moved = event.clientY - start.current; start.current = undefined;\n if (moved < -24) onUp?.(); else if (moved > 24) onDown();\n };\n return <div data-testid=\"pm-grabber\" aria-hidden=\"true\" className=\"flex shrink-0 cursor-grab touch-none justify-center pb-1\" onPointerDown={event => { start.current = event.clientY; event.currentTarget.setPointerCapture(event.pointerId); }} onPointerUp={end} onPointerCancel={() => { start.current = undefined; }}>\n <div className={cn('h-1 rounded-pm-round', wide ? 'w-[100px] bg-border' : 'w-10 bg-pm-line')} />\n </div>;\n}\n\nfunction assistantText(entry: EntryRecord): string | undefined {\n const messages = entry.model?.filter(message => message.role === 'assistant');\n if (!messages?.length) return undefined;\n const text = messages.map(message => typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('')).join('\\n').trim();\n return text || undefined;\n}\nconst failedRun = (entry: EntryRecord) => entry.model?.filter(message => message.role === 'assistant').at(-1)?.stopReason === 'error';\n\nconst callsTools = (entry: EntryRecord) => entry.model?.some(message => message.role === 'assistant' && typeof message.content !== 'string' && message.content.some(part => part.type === 'toolCall')) === true;\n\n/**\n * The PM thread's entries: sent points with their chips, the PM's reply (Markdown; plain text on touch, a bubble on desktop) and its\n * request card. `member` is the app panel's member thread (C7): a round that calls tools keeps the chat's own rows (its tool trace, mockup\n * and question cards), and a run that ends in error without a reply shows one muted line.\n */\nexport function pmEntries({ preset, text, mentions, renderEntry, renderRequest, edit, member = false }: {\n readonly preset: PmPreset; readonly text: PmText; readonly mentions: MentionsConfig | undefined;\n readonly renderEntry?: ((entry: EntryRecord) => ReturnType<NonNullable<PiChatProps['renderEntry']>>) | undefined;\n readonly renderRequest?: StudioTaskBoardSeam['renderRequest']; readonly edit: (text: string) => void; readonly member?: boolean;\n}) {\n return (entry: EntryRecord) => {\n const sent = pointMessage(entry, mentions, { preset, text });\n if (sent) return sent;\n const own = renderEntry?.(entry);\n if (own) return own;\n if (callsTools(entry) && !failedRun(entry)) return undefined;\n const reply = assistantText(entry);\n if (reply === undefined) return member && failedRun(entry) ? { inline: true, content: <p data-testid=\"pm-failed\" className=\"m-0 text-sm text-muted-foreground\">{text.failed}</p> } : undefined;\n // Headings stay at body size inside the panel.\n return { inline: true, content: <div data-testid=\"pm-reply\" data-role=\"assistant\" className=\"flex flex-col items-start gap-3.5\">\n <div className={cn('rounded-pm-bubble rounded-bl-pm-tail bg-pm-reply [&_:is(h1,h2,h3,h4,h5,h6)]:text-[length:inherit]', preset === 'touch' ? 'text-[15px] leading-[1.45]' : 'max-w-[88%] px-3 py-[9px] text-sm leading-[1.45]')}><Markdown text={reply} /></div>\n {renderRequest?.({ entry, edit })}\n </div> };\n };\n}\n\n/**\n * The PM conversation for a column (Studio, desktop preset) or any host surface: the thread and the one composer, which the strip\n * replaces while pointing. `page` is the element the point layer picks in (Studio: the app's device frame).\n */\nexport function PmChat(props: { readonly spec: ImproveSpec; readonly page: HTMLElement | null; readonly screen?: string; readonly preset?: PmPreset }) {\n const controller = props.spec.controller;\n const [instance, setInstance] = useState({ controller, sequence: 0 });\n if (instance.controller !== controller) setInstance({ controller, sequence: instance.sequence + 1 });\n return <PmChatSession key={instance.sequence} {...props} />;\n}\n\nfunction PmChatSession({ spec, page, screen, preset = 'desktop' }: { readonly spec: ImproveSpec; readonly page: HTMLElement | null; readonly screen?: string; readonly preset?: PmPreset }) {\n const [mockup, setMockup] = useState<ArtifactDescriptor>();\n const artifacts = useMemo(() => spec.chatProps?.artifacts ?? pmArtifacts(setMockup, mockup), [spec.chatProps?.artifacts, mockup]);\n const pm = usePmPanel(spec, true, page, screen, { comment: preset === 'desktop' });\n const { text } = pm;\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, text);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const strip = pm.state.mode === 'point' || pm.state.mode === 'navigate';\n const props = { preset, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: pm.focusVersion > 0 && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n return <ChatTextProvider value={chatTextFor(spec.chatProps?.locale ?? 'en')}><PmMockupProvider read={spec.readArtifact} text={text}><div data-testid=\"pm-chat\" data-mode={pm.state.mode} data-preset={preset} className={cn('flex min-h-0 flex-1 flex-col',\n '[&_[data-testid=question-option]]:min-h-11 [&_[data-testid=question-input]]:min-h-11 [&_[data-testid=question-submit]]:min-h-11 [&_[data-boring=pi-chat]]:bg-transparent [&_[data-testid=empty-state]]:hidden [&_[data-testid=transcript-scroll]>div:last-child]:flex [&_[data-testid=transcript-scroll]>div:last-child]:min-h-full [&_[data-testid=transcript-scroll]>div:last-child]:flex-col [&_[data-testid=transcript-scroll]>div:last-child]:justify-end [&_[data-boring=pi-chat]>footer]:px-3.5 [&_[data-boring=pi-chat]>footer]:pt-0 [&_[data-boring=pi-chat]>footer]:pb-3.5 [&_[data-testid=transcript]]:gap-2.5 [&_[data-testid=transcript-scroll]>div:last-child]:px-[18px] [&_[data-testid=transcript-scroll]>div:last-child]:py-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto')}>\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 {mockup && <PmMockupView artifact={mockup} text={text} onBack={() => setMockup(undefined)} />}\n <div hidden={mockup !== undefined} className={cn('min-h-0 flex-1 flex-col', mockup ? 'hidden' : 'flex')}>\n <PiChat {...withChatPlugins({ ...spec.chatProps, artifacts, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'en'} header={false} emptyLayout=\"docked\"\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={pm.sendContext} renderEntry={pmEntries({ preset, text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit })}\n renderComposer={composer => strip ? <PmComposer variant=\"strip\" {...props} /> : <PmComposer {...props} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' }} />} />\n </div>\n {pm.comment && page && <PointComment comment={pm.comment} frame={page} text={text} />}\n </div></PmMockupProvider></ChatTextProvider>;\n}\n\n/**\n * « Mes demandes » (decided 2026-10-29): open requests on top, as the compact board with their actions; finished ones (`done`, `closed`)\n * below, under « Terminées (n) », collapsed by default and absent when there are none, each with one muted label and no action.\n */\nfunction PmRequests({ seam, text }: { readonly seam: StudioTaskBoardSeam; readonly text: PmText }) {\n const [expanded, setExpanded] = useState(false);\n const open = seam.tasks.filter(task => requestOutcome(task) === undefined);\n const finished = selectTasks(seam.tasks.filter(task => requestOutcome(task) !== undefined), { filter: 'mine', currentUser: seam.currentUser });\n const label = { live: text.outcomeLive, withdrawn: text.outcomeWithdrawn, closed: text.outcomeClosed } as const;\n return <div className=\"flex flex-col gap-4 p-4\">\n {(open.length > 0 || finished.length === 0) && <TaskBoard tasks={open} variant=\"compact\" filter=\"mine\" currentUser={seam.currentUser} bar={false} title={text.requests}\n onOpen={seam.onOpen} cardActions={seam.cardActions} locale={text.locale} viewerAction=\"confirmation\" />}\n {finished.length > 0 && <section data-testid=\"pm-finished\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">\n <Button variant=\"ghost\" data-testid=\"pm-finished-toggle\" aria-expanded={expanded} aria-controls=\"pm-finished-list\" onClick={() => setExpanded(!expanded)}\n className=\"min-h-11 justify-between px-1 text-sm font-semibold text-muted-foreground\">\n {text.finished(finished.length)}<ChevronDownIcon aria-hidden=\"true\" className={cn('size-4 transition-transform', expanded && 'rotate-180')} />\n </Button>\n {expanded && <div id=\"pm-finished-list\" role=\"list\" aria-label={text.finished(finished.length)} className=\"flex flex-col gap-2\">{finished.map(task => {\n const outcome = requestOutcome(task)!;\n return <TaskRequestCard key={task.id} task={task} variant=\"compact\" locale={text.locale}\n pill={<span className={cn('contents', taskTokens.pill)}><Badge variant={outcome === 'live' ? 'secondary' : 'outline'} data-task-outcome={outcome}>{label[outcome]}</Badge></span>} />;\n })}</div>}\n </section>}\n </div>;\n}\n\nfunction Panel({ binding, pm, strip, full }: { readonly binding: Binding; readonly pm: ReturnType<typeof usePmPanel>; readonly strip: boolean; readonly full: boolean }) {\n const { spec, open, close: closeLayer } = binding;\n const [requests, setRequestsState] = useState(false);\n // An opened mockup (`present`): full height in the sheet, under « ‹ Retour ».\n const [mockup, setMockup] = useState<ArtifactDescriptor>();\n // The task source is read again when « Mes demandes » opens and when the panel opens (a 403 or 404 is « unavailable for now »).\n const setRequests = (next: boolean) => { if (next && !requests) spec.tasks?.refresh?.(); setRequestsState(next); };\n const refreshTasks = spec.tasks?.refresh;\n useEffect(() => { if (open) refreshTasks?.(); }, [open, refreshTasks]);\n // Closing leaves « Mes demandes » and the mockup: the next full panel opens on the thread.\n const close = () => { setRequestsState(false); setMockup(undefined); closeLayer(); };\n const mentions = usePmMentions(spec.tasks, spec.chatProps?.mentions, pm.text);\n const sheet = useRef<HTMLDivElement | null>(null);\n const submit = pm.sendContext;\n const { dispatch, text } = pm;\n // Plain-text sends have no context attachment, but still expand on observed native admission.\n // A comment sent from the popover keeps the strip (spec 1 §3b): only the composer's own sends open C7.\n const admitted = pm.snapshot.send.kind === 'admitted' && !pm.quietSend(pm.snapshot.send.attempt.draft.text) ? pm.snapshot.send.attempt.requestId : undefined;\n const previousAdmission = useRef<string | undefined>(undefined);\n // The full panel is its own Back step (full, then sheet, then closed): the shell owns the history entries.\n const { onFull } = binding;\n useEffect(() => { onFull?.(full, () => dispatch({ type: 'collapse' })); }, [full, onFull, dispatch]);\n useEffect(() => {\n if (admitted && admitted !== previousAdmission.current) dispatch({ type: 'full' });\n if (admitted) previousAdmission.current = admitted;\n }, [admitted, dispatch]);\n const uncertain = pm.snapshot.send.kind === 'unknown' ? pm.snapshot.send.attempt : undefined;\n const focusVersion = pm.focusVersion;\n useEffect(() => {\n if (!focusVersion || !open || strip) return;\n const input = sheet.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 }, [focusVersion, open, strip, spec.controller]);\n const edit = useCallback((value: string) => { spec.controller.setText(value); }, [spec.controller]);\n const pointProps = { preset: 'touch' as const, text, selections: pm.state.chips, onRemove: pm.remove, show: pm.state.show, onShow: (value: boolean) => pm.dispatch({ type: 'show', value }),\n onPoint: () => pm.dispatch({ type: 'point' }), onReturn: pm.returnToComposer, onOpenRecorder: () => pm.dispatch({ type: 'return' }), focusInput: focusVersion > 0 && open && !strip, voice: pm.voice, meta: pm.meta, notice: pm.sent ? text.sentToPm : undefined };\n const chatText = chatTextFor(spec.chatProps?.locale ?? 'fr');\n const review = !full && pm.meta !== undefined;\n const transcript = pm.voice.phase === 'processing' ? <p data-testid=\"pm-transcribing\" role=\"status\" className=\"m-0 px-2\">Transcription…</p> : pm.liveText && <p role=\"status\" data-testid=\"pm-live-transcript\" className=\"m-0 px-2\">{pm.liveText}</p>;\n const renderEntry = pmEntries({ preset: 'touch', text, mentions, renderEntry: spec.chatProps?.renderEntry, renderRequest: spec.tasks?.renderRequest, edit, member: true });\n const showMockup = useCallback((artifact: ArtifactDescriptor) => { setMockup(artifact); dispatch({ type: 'full' }); }, [dispatch]);\n const artifacts = useMemo(() => spec.chatProps?.artifacts ?? pmArtifacts(showMockup, mockup), [spec.chatProps?.artifacts, showMockup, mockup]);\n const aside = full && (requests || mockup !== undefined);\n const header = full ? <>\n <div className=\"flex shrink-0 items-center gap-2.5 py-2 pr-2 pl-4\">\n <div className=\"min-w-0 flex-1\">\n <h2 data-testid=\"pm-title\" className=\"m-0 truncate text-[15px] leading-tight font-pm-title\">{text.title}</h2>\n <p className=\"m-0 truncate text-xs text-muted-foreground\">{text.subtitle(spec.appName ?? spec.label ?? '')}</p>\n </div>\n <span className=\"flex shrink-0 [&_button]:px-0 [&_button]:text-sm [&_button]:font-semibold [&_button]:text-foreground [&_button]:underline\"><Button variant=\"link\" data-testid=\"pm-requests\" disabled={!spec.tasks} onClick={() => setRequests(!requests)} className=\"min-h-11\">{text.requests}</Button></span>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-11 min-w-11\"><XIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n </div>\n <Separator />\n </> : review ? <div className=\"flex shrink-0 items-center px-1 py-0.5\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-sm font-pm-title\">{text.title}</h2>\n <span data-testid=\"pm-review-hint\" className=\"text-xs text-muted-foreground\">{text.review}</span>\n </div> : <div className=\"flex shrink-0 items-center\">\n <h2 data-testid=\"pm-title\" className=\"m-0 min-w-0 flex-1 truncate text-[13px] font-pm-title\">{text.title}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.openPanel} title={text.openPanel} data-testid=\"pm-expand\" onClick={() => pm.dispatch({ type: 'full' })} className=\"min-h-9 min-w-9\"><ChevronUpIcon className=\"size-[18px]\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={text.close} title={text.close} data-testid=\"pm-close\" onClick={close} className=\"min-h-9 min-w-9\"><XIcon className=\"size-4\" aria-hidden=\"true\" /></Button>\n </div>;\n return <section hidden={!open} data-feedback-ignore=\"\" data-testid=\"pm-panel\" data-mode={pm.state.mode} data-review={review || undefined}\n className={cn(open && !strip && (full ? 'absolute inset-x-0 top-10 bottom-0 z-40 flex flex-col' : 'flex max-h-[85dvh] min-h-0 shrink-0 flex-col'), strip && 'shrink-0')}>\n <p role=\"status\" aria-live=\"polite\" data-testid=\"pm-announce\" className=\"sr-only\">{pm.announcement}</p>\n <ChatTextProvider value={chatText}><PmMockupProvider read={spec.readArtifact} text={text}>\n <div hidden={!strip}><PmComposer variant=\"strip\" {...pointProps} /></div>\n <div hidden={strip} ref={sheet} data-testid=\"pm-sheet\" className={cn('min-h-0 flex-1 flex-col rounded-t-pm-sheet bg-background', strip ? 'hidden' : 'flex', full ? 'shadow-pm-panel pt-2' : 'gap-1.5 px-3 pt-2 pb-6 shadow-pm-sheet')}>\n <Grabber wide={full} onUp={full ? undefined : () => pm.dispatch({ type: 'full' })} onDown={close} />\n {mockup && full && !requests ? <PmMockupView artifact={mockup} text={text} onBack={() => setMockup(undefined)} /> : requests && full ? <>\n <div className=\"flex shrink-0 items-center py-1 pr-2 pl-2\"><Button variant=\"ghost\" data-testid=\"pm-requests-back\" onClick={() => setRequests(false)} className=\"min-h-11\">{text.back}</Button></div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto\" data-testid=\"pm-task-board-seam\">{spec.tasks && <PmRequests seam={spec.tasks} text={text} />}</div>\n </> : header}\n {pm.problem && <p role=\"alert\" className=\"m-0 px-4 text-sm\">{pm.problem}</p>}\n {/* The member never sees a raw tool error: pi-chat's trace stays, the failed step's details and orphan error notices are hidden. */}\n <div hidden={aside} className={cn('min-h-0 flex-1 flex-col [&_[data-testid=tool-details]]:hidden [&_[data-testid=tool-error]]:hidden', aside ? 'hidden' : 'flex',\n full ? '[&_[data-boring=pi-chat]>footer]:px-4 [&_[data-boring=pi-chat]>footer]:pb-6 [&_[data-testid=transcript]]:gap-3.5 [&_[data-testid=transcript]]:text-[15px] [&_[data-testid=transcript-scroll]>div:last-child]:p-4 [&_[data-testid=transcript-history]]:min-h-0 [&_[data-testid=transcript-history]>span:not([role])]:hidden [&_[data-testid=transcript-navigation]]:pointer-events-none [&_[data-testid=transcript-navigation]]:absolute [&_[data-testid=transcript-navigation]]:inset-x-0 [&_[data-testid=transcript-navigation]]:bottom-0 [&_[data-testid=transcript-navigation]_*]:pointer-events-auto [&_[data-testid=question-option]]:min-h-11 [&_[data-testid=question-input]]:min-h-11 [&_[data-testid=question-submit]]:min-h-11'\n : '[&_[data-testid=transcript]]:hidden [&_[data-boring=pi-chat]>.relative]:hidden [&_[data-boring=pi-chat]>footer]:p-0')}>\n <PiChat {...withChatPlugins({ ...spec.chatProps, artifacts, ...(mentions ? { mentions } : {}) })} controller={spec.controller} locale={spec.chatProps?.locale ?? 'fr'} header={false} emptyLayout=\"docked\" keyboardInset={false} replyError={spec.chatProps?.replyError ?? (() => text.failed)}\n labels={{ ...spec.chatProps?.labels, placeholder: `${text.placeholderBefore}↖${text.placeholderAfter}`, add: text.attach, send: text.send }} onFiles={spec.chatProps?.onFiles ?? imageFiles}\n sendContext={submit} renderEntry={renderEntry} renderComposer={composer => <PmComposer {...pointProps} composer={{ ...composer, sendBlocked: composer.sendBlocked || pm.voice.phase === 'processing' || pm.snapshot.send.kind === 'validating' || pm.snapshot.send.kind === 'submitting', ...(uncertain ? { text: uncertain.draft.text.split(POINT_PREFACE)[0]!.trimEnd(), disabled: true } : {}) }} transcript={transcript} />} />\n </div>\n </div>\n </PmMockupProvider></ChatTextProvider>\n </section>;\n}\n",
31
31
  "type": "registry:component",
32
32
  "target": "components/pm-panel/pm-panel.tsx"
33
33
  },
@@ -39,7 +39,7 @@
39
39
  },
40
40
  {
41
41
  "path": "registry/pm-panel/use-pm-panel.ts",
42
- "content": "'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';\nimport { MASKED_NAME, accessibleNameOf, anchorOf, createOverlay, createPicker, createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PrivacyPolicy, SpeechSegment } from '@hachej/boring-feedback/page';\nimport { useVoiceAttempt } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, voiceRefusalLabel } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageHandle, VoiceMessageConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { frenchChatLabels } from '../pi-chat/labels-fr';\nimport { feedbackLabels, feedbackLabelsFr } from '../feedback/feedback-labels';\nimport type { PointChip } from './point-state';\nimport { alignedTranscript, createPointState, marker, pointReducer, renumberText } from './point-state';\nimport { pmTextFor } from './pm-text';\nimport { captureElement } from './capture';\nimport { POINT_PREFACE } from './point-message';\n\nexport interface PmTranscript { readonly text: string; readonly segments?: readonly SpeechSegment[] }\n/** Optional host voice enrichment. Authorization, retention and transcription remain with the host. */\nexport interface PmVoice {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string | PmTranscript>;\n readonly maxMs?: number;\n /** Optional live text with timings. The returned cleanup releases only this capture's listener. */\n readonly live?: (signal: AbortSignal, update: (transcript: PmTranscript) => void) => (() => void);\n}\nexport interface ImproveSpec {\n readonly controller: NativeChatController;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n readonly label?: string;\n /** The app's name in the full panel's subtitle: « Le chef de projet · {appName} ». */\n readonly appName?: string;\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\n readonly feedback?: import('@hachej/boring-feedback/ui').FeedbackSession;\n readonly policy?: PrivacyPolicy;\n readonly pointVoice?: PmVoice;\n readonly tasks?: StudioTaskBoardSeam;\n /** Reads a file the PM presented (`present`), so the app panel shows it as a mockup card and opens it in the sheet. Absent: the card shows « unavailable ». */\n readonly readArtifact?: import('./pm-mockups').PmReadArtifact;\n}\n/**\n * The app's task board (`task-board` item): the host's authorized records. The panel draws « Mes demandes » with `TaskBoard` and the @\n * picker with `task-mentions` from them; it owns no card, board or task store.\n */\nexport interface StudioTaskBoardSeam {\n readonly tasks: readonly import('@hachej/boring-ui-kit/task-board').TaskRecord[];\n /** The viewer: « Mes demandes » and @ show the tasks they requested (`requesterId`). */\n readonly currentUser: string;\n /** Opens a task: the @ chip, a card in « Mes demandes ». */\n readonly onOpen: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => void;\n /** Host-authorized compact card actions in « Mes demandes » (`TaskBoard cardActions`). */\n readonly cardActions?: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => import('react').ReactNode;\n /** The request card under a PM reply (C7, `TaskRequestCard variant=\"compact\"`), or undefined when that reply carries no request. `edit` puts the request text back in the composer (« Modifier »). */\n readonly renderRequest?: (context: { readonly entry: import('@earendil-works/pi-durable').EntryRecord; readonly edit: (text: string) => void }) => import('react').ReactNode;\n /** Reads the host's records again: the app panel calls it when it opens and when « Mes demandes » opens. */\n readonly refresh?: () => void;\n}\n\ninterface CaptureBatch {\n readonly context: object;\n readonly controller: NativeChatController;\n readonly ids: readonly string[];\n readonly text: string;\n readonly speech: PmTranscript;\n readonly priorRequestId: string | undefined;\n requestId?: string;\n}\nconst defaultPolicy = createPrivacyPolicy();\nconst NOTHING = new Error('nothing heard');\n/** The picker's overlay is the pin layer's host only while picking; pins are drawn by the overlay below (corner pins, the element's radius). */\nconst PICK_STYLES = '.box[data-tone=\"pinned\"] { display: none; } .label { display: none; } .box[data-tone=\"hover\"] { border-width: var(--pm-mark-width); } @media (hover: none) { .box[data-tone=\"hover\"] { display: none; } }';\n/** Pins: a circle centred on the element's top-right corner, accent fill, contrasting number (spec R3); the accent outline (35% app, 60% Studio) and a light fill. Every value is a theme token. */\nconst PIN_STYLES = '.box { border-style: solid; border-width: var(--pm-mark-width); border-color: color-mix(in srgb, var(--pm-mark) var(--pm-mark-line), transparent); background: color-mix(in srgb, var(--pm-mark) 6%, transparent); }'\n + ' .label { box-sizing: border-box; min-width: 24px; height: 24px; padding: 0 7px; border-radius: var(--pm-radius-round); display: flex; align-items: center; justify-content: center;'\n + ' background: var(--pm-mark); color: var(--pm-mark-foreground); font-family: var(--font-sans, inherit); font-size: 12px; font-weight: 700; line-height: 1; transform: translate(-50%, -50%); }';\n/** A tap lands on the innermost node; the pick is the nearest element a person would name (a card, a gauge, a button, a field). */\nconst NAMED = 'button, a[href], input, select, textarea, img, svg[role], [role=meter], [role=progressbar], [role=article], article, [role=region], [role=group], [role=listitem], li, [role=button], [role=link], [role=img], [aria-label]';\nfunction meaningful(element: Element, root: Element): Element {\n const named = element.closest(NAMED);\n return named && root.contains(named) ? named : element;\n}\nconst HEADING = 'h1, h2, h3, h4, h5, h6, [role=heading]';\n/**\n * Spec 1 §3's label for an element without an accessible name: its own heading, its visible text, then the nearest heading around it.\n * Read through the privacy policy, like the name and the capture: text the capture masks never becomes a label (the kind stands in).\n */\nfunction labelOf(element: Element, root: Element, policy: PrivacyPolicy): string | undefined {\n const readable = (node: Element | null) => { const name = node ? accessibleNameOf(node, policy) : MASKED_NAME; return name === MASKED_NAME ? undefined : name; };\n const own = readable(element.querySelector(HEADING)) ?? readable(element);\n if (own) return own;\n for (let around = element.parentElement; around && root.contains(around); around = around.parentElement) {\n const heading = readable(around.querySelector(HEADING));\n if (heading) return heading;\n }\n return undefined;\n}\nfunction keepMarkers(controller: NativeChatController, chips: readonly PointChip[]) {\n const text = controller.getSnapshot().draft.text;\n const missing = chips.filter(chip => !text.includes(marker(chip.number))).map(chip => marker(chip.number)).join(' ');\n if (missing) controller.setText(appendToDraft(text, missing));\n}\n\nconst ADD_TO_MESSAGE = 'boring-ui.pm.add-to-message';\nconst readAddToMessage = () => { try { return localStorage.getItem(ADD_TO_MESSAGE) === '1'; } catch { return false; } };\n\n/** The open click-to-comment popover (spec 1 §3b): the pick it comments on, the field and the two ways out. */\nexport interface PmComment {\n readonly chip: PointChip;\n readonly element: Element;\n readonly text: string;\n readonly setText: (value: string) => void;\n readonly addToMessage: boolean;\n readonly setAddToMessage: (value: boolean) => void;\n /** Enter or the send button: straight to the PM, or into the draft when « Ajouter au message » is checked. */\n readonly submit: () => void;\n /** A click outside: an empty field cancels the pick, text goes into the draft. */\n readonly outside: () => void;\n readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;\n}\n\n/** `comment`: a click in `point.idle` opens the comment popover at the pin (the desktop preset, spec 1 §3b). */\nexport function usePmPanel(spec: ImproveSpec, open: boolean, page: HTMLElement | null, screen = (typeof document === 'undefined' ? '' : document.title) || 'app', { comment: commenting = false }: { readonly comment?: boolean } = {}) {\n const text = pmTextFor(spec.chatProps?.locale);\n const [announcement, setAnnouncement] = useState('');\n const [state, dispatch] = useReducer(pointReducer, undefined, createPointState);\n const [problem, setProblem] = useState<string>();\n const [live, setLive] = useState<PmTranscript>({ text: '' });\n const [dictated, setDictated] = useState<number>();\n const [speech, setSpeech] = useState<PmTranscript>({ text: '' });\n const [focusVersion, setFocusVersion] = useState(0);\n const latest = useRef({ spec, state, open, dictated, speech });\n const mounted = useRef(false), pendingBatch = useRef<CaptureBatch | undefined>(undefined), batchSequence = useRef(0);\n const batchPrefix = useId();\n useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => { latest.current = { spec, state, open, dictated, speech }; });\n const snapshot = useSyncExternalStore(spec.controller.subscribe, spec.controller.getSnapshot, spec.controller.getSnapshot);\n const policy = spec.policy ?? defaultPolicy;\n const voiceConfig = spec.pointVoice ?? spec.voice;\n const core = useVoiceAttempt<{ readonly controller: NativeChatController; readonly config: PmVoice; readonly offset: number; readonly priorSpeech: PmTranscript }>({\n valid: item => latest.current.open && latest.current.spec.controller === item.controller,\n refused: refusal => setProblem(frenchChatLabels[voiceRefusalLabel[refusal.code ?? 'failed']]),\n });\n const coreRef = useRef(core);\n useLayoutEffect(() => { coreRef.current = core; });\n // Esc = « ✓ Terminer » while the strip shows (spec 1 §1): it keeps and transcribes the recording. Registered in a layout effect at mount, so it\n // runs before the voice attempt's own Escape (which cancels a plain dictation) and the picker's; elsewhere Escape keeps their meaning.\n const pointingRef = useRef(false), returnRef = useRef<() => void>(() => {}), removeRef = useRef<(id: string) => void>(() => {});\n // The open comment popover: Esc cancels its pick first; the strip's × is the second Esc.\n const commentRef = useRef<{ readonly id: string; readonly cancel: () => void; readonly outside: () => void } | undefined>(undefined);\n useLayoutEffect(() => {\n const onKey = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.isComposing || !pointingRef.current) return;\n event.preventDefault(); event.stopImmediatePropagation();\n if (commentRef.current) commentRef.current.cancel(); else returnRef.current();\n };\n window.addEventListener('keydown', onKey, { capture: true });\n return () => window.removeEventListener('keydown', onKey, { capture: true });\n }, []);\n const item = core.attempts[0];\n const recording = item?.phase === 'recording' || item?.phase === 'starting';\n const cancel = useCallback(() => {\n const attempt = coreRef.current.current()[0], current = latest.current;\n if (attempt?.controller === current.spec.controller) keepMarkers(attempt.controller, current.state.chips);\n coreRef.current.clear(); setLive({ text: '' });\n }, []);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a close releases this viewer's microphone and live transcript once, never native work\n useEffect(() => { if (!open) cancel(); }, [open, cancel]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new borrowed controller fences and releases the old viewer's capture once\n useEffect(() => { dispatch({ type: 'return' }); cancel(); }, [spec.controller, cancel]);\n useEffect(() => { if (open) dispatch({ type: 'return' }); }, [open]);\n useEffect(() => {\n if (!item || !item.config.live) return;\n return item.config.live(item.abort.signal, transcript => { if (coreRef.current.valid(item)) setLive(transcript); });\n }, [item]);\n const start = useCallback(() => {\n const current = latest.current, api = coreRef.current;\n const config = current.spec.pointVoice ?? current.spec.voice;\n if (!config || api.current().length || !current.open) return;\n setProblem(undefined); setLive({ text: '' });\n api.start({ controller: current.spec.controller, config, offset: current.dictated ?? 0, priorSpeech: current.speech }, config.maxMs);\n }, []);\n const finish = useCallback(() => {\n const api = coreRef.current, attempt = api.current()[0];\n if (!attempt) return;\n dispatch({ type: 'return' });\n if (attempt.phase === 'processing') return;\n if (attempt.phase === 'starting') { keepMarkers(attempt.controller, latest.current.state.chips); api.remove(attempt); setFocusVersion(value => value + 1); return; }\n void (async () => {\n try {\n const audio = await api.stop(attempt);\n if (!api.valid(attempt)) return;\n if (!audio) throw NOTHING;\n const result = await attempt.config.transcribe(audio.audio, attempt.abort.signal);\n if (!api.valid(attempt)) return;\n const transcript = typeof result === 'string' ? { text: result } : result;\n if (!transcript.text.trim()) throw NOTHING;\n const current = latest.current;\n const typed = attempt.controller.getSnapshot().draft.text;\n const timed = current.state.chips.filter(chip => !typed.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - attempt.offset) }));\n const words = alignedTranscript(transcript.text.trim(), transcript.segments, timed);\n attempt.controller.setText(appendToDraft(attempt.controller.getSnapshot().draft.text, words));\n const segments = transcript.segments?.map(segment => ({ ...segment, start: segment.start + attempt.offset, end: segment.end + attempt.offset,\n ...(segment.words ? { words: segment.words.map(word => ({ ...word, start: word.start + attempt.offset, end: word.end + attempt.offset })) } : {}) }));\n setDictated(attempt.offset + (audio.endedAt - audio.startedAt) / 1000);\n setSpeech({ text: appendToDraft(attempt.priorSpeech.text, transcript.text), segments: [...(attempt.priorSpeech.segments ?? []), ...(segments ?? [])] }); setLive({ text: '' }); setFocusVersion(value => value + 1); dispatch({ type: 'return' });\n } catch (error) {\n if (!api.valid(attempt)) return;\n // Nothing said: the picks (if any) become chips, the draft is otherwise untouched, and that is not a failure.\n keepMarkers(attempt.controller, latest.current.state.chips); setFocusVersion(value => value + 1);\n if (error !== NOTHING) setProblem('La transcription a échoué. Vos éléments et votre texte sont conservés.');\n }\n finally { if (api.valid(attempt)) api.remove(attempt); }\n })();\n }, []);\n const voice: VoiceMessageHandle = {\n canStart: Boolean(voiceConfig) && open && snapshot.connection.kind === 'connected' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'validating', phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0,\n captureSequence: core.captureSequence, levels: item?.levels ?? [], paused: item?.paused ?? false, pending: [], problem,\n start, finish, cancel,\n ...(item?.canPause ? { pause: () => core.pause(item), resume: () => core.resume(item) } : {}),\n };\n const [commentAt, setCommentAt] = useState<{ readonly id: string; readonly element: Element }>();\n const [commentText, setCommentText] = useState('');\n const [addToMessage, setAddToMessageState] = useState(readAddToMessage);\n const [sentNotice, setSentNotice] = useState(0);\n const commentingRef = useRef(commenting);\n useLayoutEffect(() => { commentingRef.current = commenting; });\n const selectedElements = useRef(new Map<string, Element>());\n const anchors = useRef(new Map<string, import('@hachej/boring-feedback/page').AppElementAnchor>());\n const selectionRoot = page?.closest('[data-pm-layout]') ?? page;\n useEffect(() => {\n if (!open || !state.show || state.mode !== 'point' || !selectionRoot) return;\n const picker = createPicker({ root: selectionRoot, policy, styles: PICK_STYLES, tap: 'pick', wheel: false, keys: 'outside-ignored',\n onPick: picked => {\n // With the comment popover open, a click on the page is a click outside it: it settles that pick and picks nothing.\n if (commentRef.current) { commentRef.current.outside(); return; }\n const element = meaningful(picked, selectionRoot);\n // Tapping an already picked element removes that pick.\n for (const [id, chosen] of selectedElements.current) if (chosen === element) { removeRef.current(id); return; }\n if (latest.current.state.chips.length >= 20) { setProblem('20 éléments maximum par message.'); return; }\n const capture = anchorOf(element, policy, { root: selectionRoot });\n if (capture.kind !== 'captured') { setProblem('Cet élément ne peut pas être joint.'); return; }\n try {\n const anchor = capture.anchor, image = captureElement(element, policy), box = element.getBoundingClientRect();\n const id = `pm-${++sequence.current}`, number = latest.current.state.nextNumber;\n const words = text.locale === 'en' ? feedbackLabels : feedbackLabelsFr;\n const role = anchor.signals.role ?? element.localName, unknown = words.element(undefined, undefined), named = words.element(role, undefined);\n const kind = named === unknown ? (text.locale === 'en' ? 'Text' : 'Texte') : named;\n const name = anchor.signals.name ?? labelOf(element, selectionRoot, policy) ?? kind;\n const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;\n const controller = latest.current.spec.controller;\n controller.setAttachments([...controller.getSnapshot().draft.attachments, { id, name: `${marker(number)} @ ${kind} · ${label} · capture jointe`, content: { type: 'image', ...image } }]);\n selectedElements.current.set(id, element); anchors.current.set(id, anchor);\n const attempt = coreRef.current.current()[0];\n const recordingNow = attempt?.phase === 'recording' || attempt?.phase === 'starting';\n const at = recordingNow ? (attempt.offset ?? 0) + (attempt.capture.progress()?.elapsedMs ?? 0) / 1000 : null;\n dispatch({ type: 'pick', chip: { id, kind, label, screen, at, box: [box.x, box.y, box.width, box.height] } });\n // While recording the voice is the comment: the click only pins.\n if (commentingRef.current && !recordingNow) { setCommentText(''); setCommentAt({ id, element }); }\n setAnnouncement(text.added(number, kind, label));\n setProblem(undefined);\n } catch { setProblem('La capture de cet élément est indisponible. Réessayez.'); }\n } });\n void picker.start();\n return () => picker.dispose();\n }, [open, state.show, state.mode, policy, selectionRoot, page, screen, text]);\n const sequence = useRef(0);\n // Messages sent from the popover: the app panel stays on the strip when they are admitted (no C7).\n const quietTexts = useRef(new Set<string>());\n useEffect(() => {\n if (!sentNotice) return;\n const timer = setTimeout(() => setSentNotice(0), 3000);\n return () => clearTimeout(timer);\n }, [sentNotice]);\n // Native attachment remove controls also remove the corresponding marker and pin.\n useEffect(() => {\n for (const chip of state.chips) if (!pendingBatch.current?.ids.includes(chip.id) && !snapshot.draft.attachments.some(attachment => attachment.id === chip.id) && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'validating') {\n dispatch({ type: 'remove', id: chip.id }); selectedElements.current.delete(chip.id); anchors.current.delete(chip.id);\n }\n }, [snapshot.draft.attachments, snapshot.send.kind, state.chips]);\n useEffect(() => {\n // Pins stay while the page is visible (strip and review); the full panel covers the page. A pin shows only on its own screen.\n if (!open || state.mode === 'full' || !state.chips.length || !selectionRoot) return;\n // Mounted beside the picked area (never inside it), so the pins read the host surface's theme tokens.\n const overlay = createOverlay({ document: selectionRoot.ownerDocument, styles: PIN_STYLES, labels: 'corner', shape: 'element', mount: selectionRoot.parentElement ?? selectionRoot.ownerDocument.body, ...(page ? { clip: page } : {}) });\n const elements = new Map<Element, string[]>();\n for (const chip of state.chips) {\n const element = selectedElements.current.get(chip.id);\n if (!element?.isConnected || !element.getClientRects().length) continue;\n elements.set(element, [...(elements.get(element) ?? []), String(chip.number)]);\n }\n overlay.draw([...elements].map(([element, numbers]) => ({ element, label: numbers.join(' '), tone: 'hover' as const })));\n return () => overlay.dispose();\n }, [open, state.mode, state.chips, selectionRoot, screen, page]);\n const completeBatch = useCallback((batch: CaptureBatch) => {\n if (!mounted.current || latest.current.spec.controller !== batch.controller || pendingBatch.current !== batch) return;\n pendingBatch.current = undefined;\n dispatch({ type: 'sent', ids: batch.ids });\n if (latest.current.speech === batch.speech) { setDictated(undefined); setSpeech({ text: '' }); }\n for (const id of batch.ids) { selectedElements.current.delete(id); anchors.current.delete(id); }\n }, []);\n const sendContext = useMemo(() => {\n const context = {};\n return {\n count: state.chips.length || (dictated !== undefined ? 1 : 0),\n attach: (text: string) => {\n if (pendingBatch.current) return Promise.resolve({ kind: 'refused' as const, reason: 'Vérifiez d’abord le message en cours.' });\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, dictatedSeconds: dictated, dictatedMs: dictated === undefined ? undefined : Math.round(dictated * 1000),\n transcriptSegments: speech.segments ?? [], alignment: speech.segments?.some(segment => segment.words?.length) ? 'word' : speech.segments?.length ? 'segment' : 'unavailable',\n policy: { version: policy.version, widened: policy.widened }, selections: state.chips.map(({ id, number, kind, label, screen, at }) => ({ n: number, kind, label, screen,\n atMs: at === null ? null : Math.round(at * 1000), t: at === null ? null : Math.round(at * 1000), selector: anchors.current.get(id)?.signals.path.join(' > '), screenshot: { imageIndex: snapshot.draft.attachments.findIndex(image => image.id === id), mimeType: snapshot.draft.attachments.find(image => image.id === id)?.content.mimeType }, anchor: anchors.current.get(id) })) };\n const attached = appendToDraft(text, `${POINT_PREFACE}${JSON.stringify(payload)}`);\n const selected: CaptureBatch = { context, controller: spec.controller, ids: state.chips.map(chip => chip.id), text: attached, speech,\n priorRequestId: 'attempt' in snapshot.send ? snapshot.send.attempt.requestId : undefined };\n pendingBatch.current = selected;\n return Promise.resolve({ kind: 'ok' as const, text: attached });\n },\n sent: () => { const selected = pendingBatch.current; if (selected?.context === context) completeBatch(selected); },\n };\n }, [state.chips, dictated, policy, speech, snapshot.draft.attachments, snapshot.send, spec.controller, batchPrefix, completeBatch]);\n // Reconciliation observes the original native request; it is never another send or a second admission authority.\n useEffect(() => {\n const batch = pendingBatch.current;\n if (!batch || batch.controller !== spec.controller) return;\n if ('attempt' in snapshot.send && snapshot.send.attempt.draft.text === batch.text && snapshot.send.attempt.requestId !== batch.priorRequestId) {\n batch.requestId ??= snapshot.send.attempt.requestId;\n if (snapshot.send.kind === 'admitted' && batch.requestId === snapshot.send.attempt.requestId) completeBatch(batch);\n } else if (snapshot.send.kind === 'blocked') pendingBatch.current = undefined;\n }, [snapshot.send, spec.controller, completeBatch]);\n const liveText = recording ? alignedTranscript(live.text, live.segments, state.chips.filter(chip => !snapshot.draft.text.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - (item?.offset ?? 0)) }))) : '';\n const remove = (id: string) => {\n const chip = state.chips.find(value => value.id === id);\n if (!chip) return;\n const draft = spec.controller.getSnapshot().draft;\n spec.controller.setText(renumberText(draft.text, chip.number));\n // Attachment names carry the marker too: they follow the renumbering.\n spec.controller.setAttachments(draft.attachments.filter(image => image.id !== id).map(image => {\n const other = state.chips.find(value => value.id === image.id);\n return other && other.number > chip.number ? { ...image, name: image.name.replace(marker(other.number), marker(other.number - 1)) } : image;\n }));\n dispatch({ type: 'remove', id }); selectedElements.current.delete(id); anchors.current.delete(id);\n setAnnouncement(text.removed(chip.number));\n };\n const commentId = commentAt?.id;\n const commentChip = commentId ? state.chips.find(chip => chip.id === commentId) : undefined;\n const closeComment = () => { commentRef.current = undefined; setCommentAt(undefined); setCommentText(''); };\n const cancelComment = () => { if (commentId) remove(commentId); closeComment(); };\n // « Ajouter au message », or a click outside with text: the chip, then the text after it, in the draft (spec 1 §3b).\n const keepComment = () => {\n if (!commentChip) return;\n const draft = spec.controller.getSnapshot().draft.text, words = commentText.trim();\n spec.controller.setText(appendToDraft(draft, words ? `${marker(commentChip.number)} ${words}` : marker(commentChip.number)));\n closeComment();\n };\n // Straight to the PM: the comment and its one chip, the capture and that pick, as their own message; the draft keeps everything else.\n const sendComment = () => {\n const words = commentText.trim();\n if (!commentChip || !words) return;\n const image = spec.controller.getSnapshot().draft.attachments.find(attachment => attachment.id === commentChip.id);\n const anchor = anchors.current.get(commentChip.id);\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, transcriptSegments: [], alignment: 'unavailable', policy: { version: policy.version, widened: policy.widened },\n selections: [{ n: 1, kind: commentChip.kind, label: commentChip.label, screen: commentChip.screen, atMs: null, t: null, selector: anchor?.signals.path.join(' > '),\n screenshot: { imageIndex: image ? 0 : -1, mimeType: image?.content.mimeType }, anchor }] };\n const message = `${words} ${marker(1)}\\n${POINT_PREFACE}${JSON.stringify(payload)}`;\n remove(commentChip.id); closeComment();\n quietTexts.current.add(message);\n const controller = spec.controller;\n void controller.send('followUp', { text: message, attachments: image ? [{ ...image, name: image.name.replace(marker(commentChip.number), marker(1)) }] : [], restore: { text: words } })\n .then(submission => { if (submission && mounted.current) setSentNotice(value => value + 1); }, () => { if (mounted.current) setProblem('L’envoi a échoué. Votre commentaire est dans le brouillon.'); });\n };\n const setAddToMessage = (value: boolean) => {\n setAddToMessageState(value);\n try { localStorage.setItem(ADD_TO_MESSAGE, value ? '1' : '0'); } catch { /* the choice lasts this session */ }\n };\n const comment: PmComment | undefined = commentChip && commentAt ? {\n chip: commentChip, element: commentAt.element, text: commentText, setText: setCommentText, addToMessage, setAddToMessage,\n submit: () => { if (addToMessage) keepComment(); else sendComment(); },\n outside: () => { if (commentText.trim()) keepComment(); else cancelComment(); },\n transcribe: voiceConfig ? async (audio, signal) => { const result = await voiceConfig.transcribe(audio, signal); return typeof result === 'string' ? result : result.text; } : undefined,\n } : undefined;\n // The popover's pick went away (sent, removed or the panel closed): nothing is left to comment on.\n if (commentAt && (!commentChip || !open || state.mode !== 'point')) setCommentAt(undefined);\n const returnToComposer = () => {\n // × settles an open popover like a click outside it; a cancelled pick gets no marker.\n const cancelled = comment && !comment.text.trim() ? comment.chip.id : undefined;\n comment?.outside();\n if (recording) { finish(); return; }\n keepMarkers(spec.controller, state.chips.filter(chip => chip.id !== cancelled));\n dispatch({ type: 'return' }); setFocusVersion(value => value + 1);\n };\n useLayoutEffect(() => {\n returnRef.current = returnToComposer; removeRef.current = remove; pointingRef.current = open && (state.mode === 'point' || state.mode === 'navigate');\n commentRef.current = comment ? { id: comment.chip.id, cancel: cancelComment, outside: comment.outside } : undefined;\n });\n const quietSend = useCallback((text: string | undefined) => text !== undefined && quietTexts.current.has(text), []);\n\n // The meta line under the draft (spec 1 §5): dictated time and captures, « Écrit · n captures » without dictation, nothing with neither.\n const meta = dictated !== undefined || state.chips.length ? text.meta(dictated, state.chips.length) : undefined;\n return { state, dispatch, voice, recording, problem, liveText, sendContext, dictated, snapshot, remove, returnToComposer, focusVersion, text, meta, announcement, comment, sent: sentNotice > 0, quietSend };\n}\n\nexport type PmPanelState = ReturnType<typeof usePmPanel>;\n",
42
+ "content": "'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';\nimport { MASKED_NAME, accessibleNameOf, anchorOf, createOverlay, createPicker, createPrivacyPolicy } from '@hachej/boring-feedback/page';\nimport type { PrivacyPolicy, SpeechSegment } from '@hachej/boring-feedback/page';\nimport { useVoiceAttempt } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { appendToDraft, voiceRefusalLabel } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageHandle, VoiceMessageConfig } from '../pi-chat/config';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { frenchChatLabels } from '../pi-chat/labels-fr';\nimport { feedbackLabels, feedbackLabelsFr } from '../feedback/feedback-labels';\nimport type { PointChip } from './point-state';\nimport { alignedTranscript, createPointState, marker, pointReducer, renumberText } from './point-state';\nimport { pmTextFor } from './pm-text';\nimport { captureElement } from './capture';\nimport { POINT_PREFACE } from './point-message';\n\nexport interface PmTranscript { readonly text: string; readonly segments?: readonly SpeechSegment[] }\n/** Optional host voice enrichment. Authorization, retention and transcription remain with the host. */\nexport interface PmVoice {\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string | PmTranscript>;\n readonly maxMs?: number;\n /** Optional live text with timings. The returned cleanup releases only this capture's listener. */\n readonly live?: (signal: AbortSignal, update: (transcript: PmTranscript) => void) => (() => void);\n}\nexport interface ImproveSpec {\n readonly controller: NativeChatController;\n readonly chatProps?: Omit<PiChatProps, 'controller'>;\n readonly label?: string;\n /** The app's name in the full panel's subtitle: « Le chef de projet · {appName} ». */\n readonly appName?: string;\n readonly voice?: VoiceMessageConfig & { readonly send?: 'auto' | 'draft' | 'message' };\n readonly feedback?: import('@hachej/boring-feedback/ui').FeedbackSession;\n readonly policy?: PrivacyPolicy;\n readonly pointVoice?: PmVoice;\n readonly tasks?: StudioTaskBoardSeam;\n /** Reads a file the PM presented (`present`), so the app panel and PmChat show it as a mockup card and open its full view. Absent: the card shows « unavailable ». */\n readonly readArtifact?: import('./pm-mockups').PmReadArtifact;\n}\n/**\n * The app's task board (`task-board` item): the host's authorized records. The panel draws « Mes demandes » with `TaskBoard` and the @\n * picker with `task-mentions` from them; it owns no card, board or task store.\n */\nexport interface StudioTaskBoardSeam {\n readonly tasks: readonly import('@hachej/boring-ui-kit/task-board').TaskRecord[];\n /** The viewer: « Mes demandes » and @ show the tasks they requested (`requesterId`). */\n readonly currentUser: string;\n /** Opens a task: the @ chip, a card in « Mes demandes ». */\n readonly onOpen: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => void;\n /** Host-authorized compact card actions in « Mes demandes » (`TaskBoard cardActions`). */\n readonly cardActions?: (task: import('@hachej/boring-ui-kit/task-board').TaskRecord) => import('react').ReactNode;\n /** The request card under a PM reply (C7, `TaskRequestCard variant=\"compact\"`), or undefined when that reply carries no request. `edit` puts the request text back in the composer (« Modifier »). */\n readonly renderRequest?: (context: { readonly entry: import('@earendil-works/pi-durable').EntryRecord; readonly edit: (text: string) => void }) => import('react').ReactNode;\n /** Reads the host's records again: the app panel calls it when it opens and when « Mes demandes » opens. */\n readonly refresh?: () => void;\n}\n\ninterface CaptureBatch {\n readonly context: object;\n readonly controller: NativeChatController;\n readonly ids: readonly string[];\n readonly text: string;\n readonly speech: PmTranscript;\n readonly priorRequestId: string | undefined;\n requestId?: string;\n}\nconst defaultPolicy = createPrivacyPolicy();\nconst NOTHING = new Error('nothing heard');\n/** The picker's overlay is the pin layer's host only while picking; pins are drawn by the overlay below (corner pins, the element's radius). */\nconst PICK_STYLES = '.box[data-tone=\"pinned\"] { display: none; } .label { display: none; } .box[data-tone=\"hover\"] { border-width: var(--pm-mark-width); } @media (hover: none) { .box[data-tone=\"hover\"] { display: none; } }';\n/** Pins: a circle centred on the element's top-right corner, accent fill, contrasting number (spec R3); the accent outline (35% app, 60% Studio) and a light fill. Every value is a theme token. */\nconst PIN_STYLES = '.box { border-style: solid; border-width: var(--pm-mark-width); border-color: color-mix(in srgb, var(--pm-mark) var(--pm-mark-line), transparent); background: color-mix(in srgb, var(--pm-mark) 6%, transparent); }'\n + ' .label { box-sizing: border-box; min-width: 24px; height: 24px; padding: 0 7px; border-radius: var(--pm-radius-round); display: flex; align-items: center; justify-content: center;'\n + ' background: var(--pm-mark); color: var(--pm-mark-foreground); font-family: var(--font-sans, inherit); font-size: 12px; font-weight: 700; line-height: 1; transform: translate(-50%, -50%); }';\n/** A tap lands on the innermost node; the pick is the nearest element a person would name (a card, a gauge, a button, a field). */\nconst NAMED = 'button, a[href], input, select, textarea, img, svg[role], [role=meter], [role=progressbar], [role=article], article, [role=region], [role=group], [role=listitem], li, [role=button], [role=link], [role=img], [aria-label]';\nfunction meaningful(element: Element, root: Element): Element {\n const named = element.closest(NAMED);\n return named && root.contains(named) ? named : element;\n}\nconst HEADING = 'h1, h2, h3, h4, h5, h6, [role=heading]';\n/**\n * Spec 1 §3's label for an element without an accessible name: its own heading, its visible text, then the nearest heading around it.\n * Read through the privacy policy, like the name and the capture: text the capture masks never becomes a label (the kind stands in).\n */\nfunction labelOf(element: Element, root: Element, policy: PrivacyPolicy): string | undefined {\n const readable = (node: Element | null) => { const name = node ? accessibleNameOf(node, policy) : MASKED_NAME; return name === MASKED_NAME ? undefined : name; };\n const own = readable(element.querySelector(HEADING)) ?? readable(element);\n if (own) return own;\n for (let around = element.parentElement; around && root.contains(around); around = around.parentElement) {\n const heading = readable(around.querySelector(HEADING));\n if (heading) return heading;\n }\n return undefined;\n}\nfunction keepMarkers(controller: NativeChatController, chips: readonly PointChip[]) {\n const text = controller.getSnapshot().draft.text;\n const missing = chips.filter(chip => !text.includes(marker(chip.number))).map(chip => marker(chip.number)).join(' ');\n if (missing) controller.setText(appendToDraft(text, missing));\n}\n\nconst ADD_TO_MESSAGE = 'boring-ui.pm.add-to-message';\nconst readAddToMessage = () => { try { return localStorage.getItem(ADD_TO_MESSAGE) === '1'; } catch { return false; } };\n\n/** The open click-to-comment popover (spec 1 §3b): the pick it comments on, the field and the two ways out. */\nexport interface PmComment {\n readonly chip: PointChip;\n readonly element: Element;\n readonly text: string;\n readonly setText: (value: string) => void;\n readonly addToMessage: boolean;\n readonly setAddToMessage: (value: boolean) => void;\n /** Enter or the send button: straight to the PM, or into the draft when « Ajouter au message » is checked. */\n readonly submit: () => void;\n /** A click outside: an empty field cancels the pick, text goes into the draft. */\n readonly outside: () => void;\n readonly transcribe: ((audio: Blob, signal: AbortSignal) => Promise<string>) | undefined;\n}\n\n/** `comment`: a click in `point.idle` opens the comment popover at the pin (the desktop preset, spec 1 §3b). */\nexport function usePmPanel(spec: ImproveSpec, open: boolean, page: HTMLElement | null, screen = (typeof document === 'undefined' ? '' : document.title) || 'app', { comment: commenting = false }: { readonly comment?: boolean } = {}) {\n const text = pmTextFor(spec.chatProps?.locale);\n const [announcement, setAnnouncement] = useState('');\n const [state, dispatch] = useReducer(pointReducer, undefined, createPointState);\n const [problem, setProblem] = useState<string>();\n const [live, setLive] = useState<PmTranscript>({ text: '' });\n const [dictated, setDictated] = useState<number>();\n const [speech, setSpeech] = useState<PmTranscript>({ text: '' });\n const [focusVersion, setFocusVersion] = useState(0);\n const latest = useRef({ spec, state, open, dictated, speech });\n const mounted = useRef(false), pendingBatch = useRef<CaptureBatch | undefined>(undefined), batchSequence = useRef(0);\n const batchPrefix = useId();\n useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => { latest.current = { spec, state, open, dictated, speech }; });\n const snapshot = useSyncExternalStore(spec.controller.subscribe, spec.controller.getSnapshot, spec.controller.getSnapshot);\n const policy = spec.policy ?? defaultPolicy;\n const voiceConfig = spec.pointVoice ?? spec.voice;\n const core = useVoiceAttempt<{ readonly controller: NativeChatController; readonly config: PmVoice; readonly offset: number; readonly priorSpeech: PmTranscript }>({\n valid: item => latest.current.open && latest.current.spec.controller === item.controller,\n refused: refusal => setProblem(frenchChatLabels[voiceRefusalLabel[refusal.code ?? 'failed']]),\n });\n const coreRef = useRef(core);\n useLayoutEffect(() => { coreRef.current = core; });\n // Esc = « ✓ Terminer » while the strip shows (spec 1 §1): it keeps and transcribes the recording. Registered in a layout effect at mount, so it\n // runs before the voice attempt's own Escape (which cancels a plain dictation) and the picker's; elsewhere Escape keeps their meaning.\n const pointingRef = useRef(false), returnRef = useRef<() => void>(() => {}), removeRef = useRef<(id: string) => void>(() => {});\n // The open comment popover: Esc cancels its pick first; the strip's × is the second Esc.\n const commentRef = useRef<{ readonly id: string; readonly cancel: () => void; readonly outside: () => void } | undefined>(undefined);\n useLayoutEffect(() => {\n const onKey = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.isComposing || !pointingRef.current) return;\n event.preventDefault(); event.stopImmediatePropagation();\n if (commentRef.current) commentRef.current.cancel(); else returnRef.current();\n };\n window.addEventListener('keydown', onKey, { capture: true });\n return () => window.removeEventListener('keydown', onKey, { capture: true });\n }, []);\n const item = core.attempts[0];\n const recording = item?.phase === 'recording' || item?.phase === 'starting';\n const cancel = useCallback(() => {\n const attempt = coreRef.current.current()[0], current = latest.current;\n if (attempt?.controller === current.spec.controller) keepMarkers(attempt.controller, current.state.chips);\n coreRef.current.clear(); setLive({ text: '' });\n }, []);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a close releases this viewer's microphone and live transcript once, never native work\n useEffect(() => { if (!open) cancel(); }, [open, cancel]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new borrowed controller fences and releases the old viewer's capture once\n useEffect(() => { dispatch({ type: 'return' }); cancel(); }, [spec.controller, cancel]);\n useEffect(() => { if (open) dispatch({ type: 'return' }); }, [open]);\n useEffect(() => {\n if (!item || !item.config.live) return;\n return item.config.live(item.abort.signal, transcript => { if (coreRef.current.valid(item)) setLive(transcript); });\n }, [item]);\n const start = useCallback(() => {\n const current = latest.current, api = coreRef.current;\n const config = current.spec.pointVoice ?? current.spec.voice;\n if (!config || api.current().length || !current.open) return;\n setProblem(undefined); setLive({ text: '' });\n api.start({ controller: current.spec.controller, config, offset: current.dictated ?? 0, priorSpeech: current.speech }, config.maxMs);\n }, []);\n const finish = useCallback(() => {\n const api = coreRef.current, attempt = api.current()[0];\n if (!attempt) return;\n dispatch({ type: 'return' });\n if (attempt.phase === 'processing') return;\n if (attempt.phase === 'starting') { keepMarkers(attempt.controller, latest.current.state.chips); api.remove(attempt); setFocusVersion(value => value + 1); return; }\n void (async () => {\n try {\n const audio = await api.stop(attempt);\n if (!api.valid(attempt)) return;\n if (!audio) throw NOTHING;\n const result = await attempt.config.transcribe(audio.audio, attempt.abort.signal);\n if (!api.valid(attempt)) return;\n const transcript = typeof result === 'string' ? { text: result } : result;\n if (!transcript.text.trim()) throw NOTHING;\n const current = latest.current;\n const typed = attempt.controller.getSnapshot().draft.text;\n const timed = current.state.chips.filter(chip => !typed.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - attempt.offset) }));\n const words = alignedTranscript(transcript.text.trim(), transcript.segments, timed);\n attempt.controller.setText(appendToDraft(attempt.controller.getSnapshot().draft.text, words));\n const segments = transcript.segments?.map(segment => ({ ...segment, start: segment.start + attempt.offset, end: segment.end + attempt.offset,\n ...(segment.words ? { words: segment.words.map(word => ({ ...word, start: word.start + attempt.offset, end: word.end + attempt.offset })) } : {}) }));\n setDictated(attempt.offset + (audio.endedAt - audio.startedAt) / 1000);\n setSpeech({ text: appendToDraft(attempt.priorSpeech.text, transcript.text), segments: [...(attempt.priorSpeech.segments ?? []), ...(segments ?? [])] }); setLive({ text: '' }); setFocusVersion(value => value + 1); dispatch({ type: 'return' });\n } catch (error) {\n if (!api.valid(attempt)) return;\n // Nothing said: the picks (if any) become chips, the draft is otherwise untouched, and that is not a failure.\n keepMarkers(attempt.controller, latest.current.state.chips); setFocusVersion(value => value + 1);\n if (error !== NOTHING) setProblem('La transcription a échoué. Vos éléments et votre texte sont conservés.');\n }\n finally { if (api.valid(attempt)) api.remove(attempt); }\n })();\n }, []);\n const voice: VoiceMessageHandle = {\n canStart: Boolean(voiceConfig) && open && snapshot.connection.kind === 'connected' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'validating', phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0,\n captureSequence: core.captureSequence, levels: item?.levels ?? [], paused: item?.paused ?? false, pending: [], problem,\n start, finish, cancel,\n ...(item?.canPause ? { pause: () => core.pause(item), resume: () => core.resume(item) } : {}),\n };\n const [commentAt, setCommentAt] = useState<{ readonly id: string; readonly element: Element }>();\n const [commentText, setCommentText] = useState('');\n const [addToMessage, setAddToMessageState] = useState(readAddToMessage);\n const [sentNotice, setSentNotice] = useState(0);\n const commentingRef = useRef(commenting);\n useLayoutEffect(() => { commentingRef.current = commenting; });\n const selectedElements = useRef(new Map<string, Element>());\n const anchors = useRef(new Map<string, import('@hachej/boring-feedback/page').AppElementAnchor>());\n const selectionRoot = page?.closest('[data-pm-layout]') ?? page;\n useEffect(() => {\n if (!open || !state.show || state.mode !== 'point' || !selectionRoot) return;\n const picker = createPicker({ root: selectionRoot, policy, styles: PICK_STYLES, tap: 'pick', wheel: false, keys: 'outside-ignored',\n onPick: picked => {\n // With the comment popover open, a click on the page is a click outside it: it settles that pick and picks nothing.\n if (commentRef.current) { commentRef.current.outside(); return; }\n const element = meaningful(picked, selectionRoot);\n // Tapping an already picked element removes that pick.\n for (const [id, chosen] of selectedElements.current) if (chosen === element) { removeRef.current(id); return; }\n if (latest.current.state.chips.length >= 20) { setProblem('20 éléments maximum par message.'); return; }\n const capture = anchorOf(element, policy, { root: selectionRoot });\n if (capture.kind !== 'captured') { setProblem('Cet élément ne peut pas être joint.'); return; }\n try {\n const anchor = capture.anchor, image = captureElement(element, policy), box = element.getBoundingClientRect();\n const id = `pm-${++sequence.current}`, number = latest.current.state.nextNumber;\n const words = text.locale === 'en' ? feedbackLabels : feedbackLabelsFr;\n const role = anchor.signals.role ?? element.localName, unknown = words.element(undefined, undefined), named = words.element(role, undefined);\n const kind = named === unknown ? (text.locale === 'en' ? 'Text' : 'Texte') : named;\n const name = anchor.signals.name ?? labelOf(element, selectionRoot, policy) ?? kind;\n const label = name.length > 40 ? `${name.slice(0, 39)}…` : name;\n const controller = latest.current.spec.controller;\n controller.setAttachments([...controller.getSnapshot().draft.attachments, { id, name: `${marker(number)} @ ${kind} · ${label} · capture jointe`, content: { type: 'image', ...image } }]);\n selectedElements.current.set(id, element); anchors.current.set(id, anchor);\n const attempt = coreRef.current.current()[0];\n const recordingNow = attempt?.phase === 'recording' || attempt?.phase === 'starting';\n const at = recordingNow ? (attempt.offset ?? 0) + (attempt.capture.progress()?.elapsedMs ?? 0) / 1000 : null;\n dispatch({ type: 'pick', chip: { id, kind, label, screen, at, box: [box.x, box.y, box.width, box.height] } });\n // While recording the voice is the comment: the click only pins.\n if (commentingRef.current && !recordingNow) { setCommentText(''); setCommentAt({ id, element }); }\n setAnnouncement(text.added(number, kind, label));\n setProblem(undefined);\n } catch { setProblem('La capture de cet élément est indisponible. Réessayez.'); }\n } });\n void picker.start();\n return () => picker.dispose();\n }, [open, state.show, state.mode, policy, selectionRoot, page, screen, text]);\n const sequence = useRef(0);\n // Messages sent from the popover: the app panel stays on the strip when they are admitted (no C7).\n const quietTexts = useRef(new Set<string>());\n useEffect(() => {\n if (!sentNotice) return;\n const timer = setTimeout(() => setSentNotice(0), 3000);\n return () => clearTimeout(timer);\n }, [sentNotice]);\n // Native attachment remove controls also remove the corresponding marker and pin.\n useEffect(() => {\n for (const chip of state.chips) if (!pendingBatch.current?.ids.includes(chip.id) && !snapshot.draft.attachments.some(attachment => attachment.id === chip.id) && snapshot.send.kind !== 'submitting' && snapshot.send.kind !== 'unknown' && snapshot.send.kind !== 'validating') {\n dispatch({ type: 'remove', id: chip.id }); selectedElements.current.delete(chip.id); anchors.current.delete(chip.id);\n }\n }, [snapshot.draft.attachments, snapshot.send.kind, state.chips]);\n useEffect(() => {\n // Pins stay while the page is visible (strip and review); the full panel covers the page. A pin shows only on its own screen.\n if (!open || state.mode === 'full' || !state.chips.length || !selectionRoot) return;\n // Mounted beside the picked area (never inside it), so the pins read the host surface's theme tokens.\n const overlay = createOverlay({ document: selectionRoot.ownerDocument, styles: PIN_STYLES, labels: 'corner', shape: 'element', mount: selectionRoot.parentElement ?? selectionRoot.ownerDocument.body, ...(page ? { clip: page } : {}) });\n const elements = new Map<Element, string[]>();\n for (const chip of state.chips) {\n const element = selectedElements.current.get(chip.id);\n if (!element?.isConnected || !element.getClientRects().length) continue;\n elements.set(element, [...(elements.get(element) ?? []), String(chip.number)]);\n }\n overlay.draw([...elements].map(([element, numbers]) => ({ element, label: numbers.join(' '), tone: 'hover' as const })));\n return () => overlay.dispose();\n }, [open, state.mode, state.chips, selectionRoot, screen, page]);\n const completeBatch = useCallback((batch: CaptureBatch) => {\n if (!mounted.current || latest.current.spec.controller !== batch.controller || pendingBatch.current !== batch) return;\n pendingBatch.current = undefined;\n dispatch({ type: 'sent', ids: batch.ids });\n if (latest.current.speech === batch.speech) { setDictated(undefined); setSpeech({ text: '' }); }\n for (const id of batch.ids) { selectedElements.current.delete(id); anchors.current.delete(id); }\n }, []);\n const sendContext = useMemo(() => {\n const context = {};\n return {\n count: state.chips.length || (dictated !== undefined ? 1 : 0),\n attach: (text: string) => {\n if (pendingBatch.current) return Promise.resolve({ kind: 'refused' as const, reason: 'Vérifiez d’abord le message en cours.' });\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, dictatedSeconds: dictated, dictatedMs: dictated === undefined ? undefined : Math.round(dictated * 1000),\n transcriptSegments: speech.segments ?? [], alignment: speech.segments?.some(segment => segment.words?.length) ? 'word' : speech.segments?.length ? 'segment' : 'unavailable',\n policy: { version: policy.version, widened: policy.widened }, selections: state.chips.map(({ id, number, kind, label, screen, at }) => ({ n: number, kind, label, screen,\n atMs: at === null ? null : Math.round(at * 1000), t: at === null ? null : Math.round(at * 1000), selector: anchors.current.get(id)?.signals.path.join(' > '), screenshot: { imageIndex: snapshot.draft.attachments.findIndex(image => image.id === id), mimeType: snapshot.draft.attachments.find(image => image.id === id)?.content.mimeType }, anchor: anchors.current.get(id) })) };\n const attached = appendToDraft(text, `${POINT_PREFACE}${JSON.stringify(payload)}`);\n const selected: CaptureBatch = { context, controller: spec.controller, ids: state.chips.map(chip => chip.id), text: attached, speech,\n priorRequestId: 'attempt' in snapshot.send ? snapshot.send.attempt.requestId : undefined };\n pendingBatch.current = selected;\n return Promise.resolve({ kind: 'ok' as const, text: attached });\n },\n sent: () => { const selected = pendingBatch.current; if (selected?.context === context) completeBatch(selected); },\n };\n }, [state.chips, dictated, policy, speech, snapshot.draft.attachments, snapshot.send, spec.controller, batchPrefix, completeBatch]);\n // Reconciliation observes the original native request; it is never another send or a second admission authority.\n useEffect(() => {\n const batch = pendingBatch.current;\n if (!batch || batch.controller !== spec.controller) return;\n if ('attempt' in snapshot.send && snapshot.send.attempt.draft.text === batch.text && snapshot.send.attempt.requestId !== batch.priorRequestId) {\n batch.requestId ??= snapshot.send.attempt.requestId;\n if (snapshot.send.kind === 'admitted' && batch.requestId === snapshot.send.attempt.requestId) completeBatch(batch);\n } else if (snapshot.send.kind === 'blocked') pendingBatch.current = undefined;\n }, [snapshot.send, spec.controller, completeBatch]);\n const liveText = recording ? alignedTranscript(live.text, live.segments, state.chips.filter(chip => !snapshot.draft.text.includes(marker(chip.number))).map(chip => ({ ...chip, at: chip.at === null ? null : Math.max(0, chip.at - (item?.offset ?? 0)) }))) : '';\n const remove = (id: string) => {\n const chip = state.chips.find(value => value.id === id);\n if (!chip) return;\n const draft = spec.controller.getSnapshot().draft;\n spec.controller.setText(renumberText(draft.text, chip.number));\n // Attachment names carry the marker too: they follow the renumbering.\n spec.controller.setAttachments(draft.attachments.filter(image => image.id !== id).map(image => {\n const other = state.chips.find(value => value.id === image.id);\n return other && other.number > chip.number ? { ...image, name: image.name.replace(marker(other.number), marker(other.number - 1)) } : image;\n }));\n dispatch({ type: 'remove', id }); selectedElements.current.delete(id); anchors.current.delete(id);\n setAnnouncement(text.removed(chip.number));\n };\n const commentId = commentAt?.id;\n const commentChip = commentId ? state.chips.find(chip => chip.id === commentId) : undefined;\n const closeComment = () => { commentRef.current = undefined; setCommentAt(undefined); setCommentText(''); };\n const cancelComment = () => { if (commentId) remove(commentId); closeComment(); };\n // « Ajouter au message », or a click outside with text: the chip, then the text after it, in the draft (spec 1 §3b).\n const keepComment = () => {\n if (!commentChip) return;\n const draft = spec.controller.getSnapshot().draft.text, words = commentText.trim();\n spec.controller.setText(appendToDraft(draft, words ? `${marker(commentChip.number)} ${words}` : marker(commentChip.number)));\n closeComment();\n };\n // Straight to the PM: the comment and its one chip, the capture and that pick, as their own message; the draft keeps everything else.\n const sendComment = () => {\n const words = commentText.trim();\n if (!commentChip || !words) return;\n const image = spec.controller.getSnapshot().draft.attachments.find(attachment => attachment.id === commentChip.id);\n const anchor = anchors.current.get(commentChip.id);\n const payload = { format: 'pm.points@1', captureBatch: `${batchPrefix}-${++batchSequence.current}`, transcriptSegments: [], alignment: 'unavailable', policy: { version: policy.version, widened: policy.widened },\n selections: [{ n: 1, kind: commentChip.kind, label: commentChip.label, screen: commentChip.screen, atMs: null, t: null, selector: anchor?.signals.path.join(' > '),\n screenshot: { imageIndex: image ? 0 : -1, mimeType: image?.content.mimeType }, anchor }] };\n const message = `${words} ${marker(1)}\\n${POINT_PREFACE}${JSON.stringify(payload)}`;\n remove(commentChip.id); closeComment();\n quietTexts.current.add(message);\n const controller = spec.controller;\n void controller.send('followUp', { text: message, attachments: image ? [{ ...image, name: image.name.replace(marker(commentChip.number), marker(1)) }] : [], restore: { text: words } })\n .then(submission => { if (submission && mounted.current) setSentNotice(value => value + 1); }, () => { if (mounted.current) setProblem('L’envoi a échoué. Votre commentaire est dans le brouillon.'); });\n };\n const setAddToMessage = (value: boolean) => {\n setAddToMessageState(value);\n try { localStorage.setItem(ADD_TO_MESSAGE, value ? '1' : '0'); } catch { /* the choice lasts this session */ }\n };\n const comment: PmComment | undefined = commentChip && commentAt ? {\n chip: commentChip, element: commentAt.element, text: commentText, setText: setCommentText, addToMessage, setAddToMessage,\n submit: () => { if (addToMessage) keepComment(); else sendComment(); },\n outside: () => { if (commentText.trim()) keepComment(); else cancelComment(); },\n transcribe: voiceConfig ? async (audio, signal) => { const result = await voiceConfig.transcribe(audio, signal); return typeof result === 'string' ? result : result.text; } : undefined,\n } : undefined;\n // The popover's pick went away (sent, removed or the panel closed): nothing is left to comment on.\n if (commentAt && (!commentChip || !open || state.mode !== 'point')) setCommentAt(undefined);\n const returnToComposer = () => {\n // × settles an open popover like a click outside it; a cancelled pick gets no marker.\n const cancelled = comment && !comment.text.trim() ? comment.chip.id : undefined;\n comment?.outside();\n if (recording) { finish(); return; }\n keepMarkers(spec.controller, state.chips.filter(chip => chip.id !== cancelled));\n dispatch({ type: 'return' }); setFocusVersion(value => value + 1);\n };\n useLayoutEffect(() => {\n returnRef.current = returnToComposer; removeRef.current = remove; pointingRef.current = open && (state.mode === 'point' || state.mode === 'navigate');\n commentRef.current = comment ? { id: comment.chip.id, cancel: cancelComment, outside: comment.outside } : undefined;\n });\n const quietSend = useCallback((text: string | undefined) => text !== undefined && quietTexts.current.has(text), []);\n\n // The meta line under the draft (spec 1 §5): dictated time and captures, « Écrit · n captures » without dictation, nothing with neither.\n const meta = dictated !== undefined || state.chips.length ? text.meta(dictated, state.chips.length) : undefined;\n return { state, dispatch, voice, recording, problem, liveText, sendContext, dictated, snapshot, remove, returnToComposer, focusVersion, text, meta, announcement, comment, sent: sentNotice > 0, quietSend };\n}\n\nexport type PmPanelState = ReturnType<typeof usePmPanel>;\n",
43
43
  "type": "registry:hook",
44
44
  "target": "components/pm-panel/use-pm-panel.ts"
45
45
  },
@@ -99,7 +99,7 @@
99
99
  },
100
100
  {
101
101
  "path": "registry/pm-panel/pm-mockups.tsx",
102
- "content": "'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronRightIcon } from 'lucide-react';\nimport type { ArtifactDescriptor, PresentedArtifact } from '@hachej/boring-ui-kit/chat-view';\nimport { artifactVersionKey, recogniseArtifact } from '@hachej/boring-ui-kit/chat-view';\nimport { Button } from '@/components/ui/button';\nimport { InteractiveFrame } from '../viewer-frame/interactive-html';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { Shimmer } from '../pi-chat/shimmer';\nimport type { ChatArtifactCardProps, ChatArtifactsConfig } from '../pi-chat/config';\nimport type { PmText } from './pm-text';\n\n/**\n * Reads a file the PM presented (`present`, a `boring.artifact` descriptor) as text, at the descriptor's revision; undefined when it can't.\n * The one line reads it through the app's PM door (`{base}/resources`, see `use-improve-agent.ts`).\n */\nexport type PmReadArtifact = (artifact: ArtifactDescriptor, signal: AbortSignal) => Promise<string | undefined>;\n\ntype Loaded = { readonly text: string } | { readonly failed: true } | undefined;\ninterface Mockups { readonly read: PmReadArtifact | undefined; readonly text: PmText; readonly cache: Map<string, Promise<string | undefined>> }\nconst MockupContext = createContext<Mockups | null>(null);\n\n/** The panel's presented files: the reader and its cache (one read per revision, shared by the thumbnail and the full view). */\nexport function PmMockupProvider({ read, text, children }: { readonly read: PmReadArtifact | undefined; readonly text: PmText; readonly children: ReactNode }) {\n const [cache] = useState(() => new Map<string, Promise<string | undefined>>());\n const value = useMemo(() => ({ read, text, cache }), [read, text, cache]);\n return <MockupContext.Provider value={value}>{children}</MockupContext.Provider>;\n}\n\nfunction useMockup(artifact: ArtifactDescriptor | undefined): Loaded {\n const context = useContext(MockupContext);\n const [loaded, setLoaded] = useState<{ readonly key: string; readonly value: Loaded }>();\n const key = artifact ? artifactVersionKey(artifact) : undefined;\n useEffect(() => {\n if (!artifact || !key || !context) return;\n const read = context.read;\n if (!read) return;\n let pending = context.cache.get(key);\n if (!pending) {\n const abort = new AbortController();\n pending = read(artifact, abort.signal).catch(() => undefined);\n context.cache.set(key, pending);\n void pending.then(text => { if (text === undefined) context.cache.delete(key); });\n }\n let current = true;\n void pending.then(text => { if (current) setLoaded({ key, value: text === undefined ? { failed: true } : { text } }); });\n return () => { current = false; };\n }, [artifact, key, context]);\n if (artifact && context && !context.read) return { failed: true };\n return loaded && loaded.key === key ? loaded.value : undefined;\n}\n\n/** The mockup itself: HTML and SVG in a sandboxed frame (scripts only, opaque origin, no network), Markdown drawn, anything else as text. */\nfunction MockupBody({ artifact, text, testId }: { readonly artifact: ArtifactDescriptor; readonly text: string; readonly testId: string }) {\n if (artifact.type === 'html' || artifact.type === 'svg') return <InteractiveFrame text={text} sources={[]} title={artifact.title} testId={testId} />;\n if (artifact.type === 'markdown') return <div data-testid={`${testId}-markdown`} className=\"min-h-0 flex-1 overflow-auto p-4 text-[15px]\"><Markdown text={text} /></div>;\n return <pre data-testid={`${testId}-text`} className=\"m-0 min-h-0 flex-1 overflow-auto p-4 text-xs whitespace-pre-wrap\">{text}</pre>;\n}\n\nconst descriptorOf = (artifact: ChatArtifactCardProps['artifact']): ArtifactDescriptor | undefined => {\n const descriptor = artifact.state === 'ready' ? artifact.descriptor as PresentedArtifact : undefined;\n return descriptor && descriptor.type !== 'task' ? descriptor : undefined;\n};\n\n/**\n * A presented mockup in the member thread (C7): one row per mockup, so two or three presented in a row read as a vertical list. A small\n * live thumbnail (the sandboxed page at phone width, scaled down), the title and « Ouvrir › »; the whole row (min 64px) opens it full height.\n */\nexport function PmArtifactCard({ artifact, open, onOpen }: ChatArtifactCardProps) {\n const context = useContext(MockupContext);\n const descriptor = descriptorOf(artifact);\n const loaded = useMockup(descriptor);\n const title = descriptor?.title ?? artifact.title ?? '';\n return <div data-testid=\"pm-mockup\" data-state={artifact.state} data-open={open ? 'true' : undefined} className=\"relative my-1 flex min-h-16 w-full max-w-sm items-center gap-3 rounded-pm-box bg-background p-2 ring-1 ring-border\">\n <div aria-hidden=\"true\" inert className=\"relative h-24 w-16 shrink-0 overflow-hidden rounded-pm-control bg-muted ring-1 ring-border\">\n {descriptor && loaded && 'text' in loaded && <div className=\"pointer-events-none absolute top-0 left-0 flex h-[585px] w-[390px] origin-top-left scale-[0.1641] flex-col\">\n <MockupBody artifact={descriptor} text={loaded.text} testId=\"pm-mockup-thumb\" />\n </div>}\n </div>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"pm-mockup-title\" className=\"truncate text-sm font-medium\">{artifact.state === 'ready' ? title : <Shimmer>{title}</Shimmer>}</span>\n {loaded && 'failed' in loaded ? <span className=\"text-[13px] text-muted-foreground\">{context?.text.mockupUnavailable}</span>\n : artifact.state === 'ready' && <span className=\"flex items-center text-[13px] text-muted-foreground\">{context?.text.openMockup}<ChevronRightIcon className=\"size-3.5\" aria-hidden=\"true\" /></span>}\n </span>\n {descriptor && onOpen && <Button variant=\"link\" data-testid=\"pm-mockup-open\" aria-label={`${context?.text.openMockup ?? ''} ${title}`} onClick={onOpen} className=\"absolute inset-0 h-auto w-full\" />}\n </div>;\n}\n\n/** The opened mockup, full height inside the sheet, under « ‹ Retour ». */\nexport function PmMockupView({ artifact, text, onBack }: { readonly artifact: ArtifactDescriptor; readonly text: PmText; readonly onBack: () => void }) {\n const loaded = useMockup(artifact);\n return <div data-testid=\"pm-mockup-view\" className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex shrink-0 items-center gap-1 py-1 pr-4 pl-2\">\n <Button variant=\"ghost\" data-testid=\"pm-mockup-back\" onClick={onBack} className=\"min-h-11\">{text.back}</Button>\n <span data-testid=\"pm-mockup-view-title\" className=\"min-w-0 flex-1 truncate text-right text-[13px] text-muted-foreground\">{artifact.title}</span>\n </div>\n {loaded && 'text' in loaded ? <MockupBody artifact={artifact} text={loaded.text} testId=\"pm-mockup-full\" />\n : <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{loaded ? text.mockupUnavailable : text.mockupLoading}</p>}\n </div>;\n}\n\n/** The panel's artifacts config when the host gives none: `present` results become `PmArtifactCard`s that open in the sheet. */\nexport function pmArtifacts(open: (artifact: ArtifactDescriptor) => void, shown: ArtifactDescriptor | undefined): ChatArtifactsConfig {\n return {\n open: ((artifact: PresentedArtifact) => { if (artifact.type !== 'task') open(artifact); }) as ChatArtifactsConfig['open'],\n isOpen: ((artifact: PresentedArtifact) => shown !== undefined && artifact.type !== 'task' && artifactVersionKey(artifact) === artifactVersionKey(shown)) as NonNullable<ChatArtifactsConfig['isOpen']>,\n recognise: recogniseArtifact, Card: PmArtifactCard,\n };\n}\n",
102
+ "content": "'use client';\n\nimport { createContext, useContext, useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronRightIcon } from 'lucide-react';\nimport type { ArtifactDescriptor, PresentedArtifact } from '@hachej/boring-ui-kit/chat-view';\nimport { recogniseArtifact } from '@hachej/boring-ui-kit/chat-view';\nimport { Button } from '@/components/ui/button';\nimport { InteractiveFrame } from '../viewer-frame/interactive-html';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { Shimmer } from '../pi-chat/shimmer';\nimport type { ChatArtifactCardProps, ChatArtifactsConfig } from '../pi-chat/config';\nimport type { PmText } from './pm-text';\n\n/**\n * Reads a file the PM presented (`present`, a `boring.artifact` descriptor) as text, at the descriptor's revision; undefined when it can't.\n * The one line reads it through the app's PM door (`{base}/resources`, see `use-improve-agent.ts`).\n */\nexport type PmReadArtifact = (artifact: ArtifactDescriptor, signal: AbortSignal) => Promise<string | undefined>;\n\n/** Room agents can present the same path/revision from different providers or views. */\nconst mockupKey = (artifact: ArtifactDescriptor): string => JSON.stringify([artifact.target, artifact.revision]);\n\ntype Loaded = { readonly text: string } | { readonly failed: true } | undefined;\ninterface Mockups { readonly read: PmReadArtifact | undefined; readonly text: PmText; readonly cache: Map<string, Promise<string | undefined>> }\nconst MockupContext = createContext<Mockups | null>(null);\n\n/** The panel's presented files: the reader and its cache (one read per revision, shared by the thumbnail and the full view). */\nexport function PmMockupProvider({ read, text, children }: { readonly read: PmReadArtifact | undefined; readonly text: PmText; readonly children: ReactNode }) {\n const [cache] = useState(() => new Map<string, Promise<string | undefined>>());\n const value = useMemo(() => ({ read, text, cache }), [read, text, cache]);\n return <MockupContext.Provider value={value}>{children}</MockupContext.Provider>;\n}\n\nfunction useMockup(artifact: ArtifactDescriptor | undefined): Loaded {\n const context = useContext(MockupContext);\n const [loaded, setLoaded] = useState<{ readonly key: string; readonly value: Loaded }>();\n const key = artifact ? mockupKey(artifact) : undefined;\n useEffect(() => {\n if (!artifact || !key || !context) return;\n const read = context.read;\n if (!read) return;\n let pending = context.cache.get(key);\n if (!pending) {\n const abort = new AbortController();\n pending = read(artifact, abort.signal).catch(() => undefined);\n context.cache.set(key, pending);\n void pending.then(text => { if (text === undefined) context.cache.delete(key); });\n }\n let current = true;\n void pending.then(text => { if (current) setLoaded({ key, value: text === undefined ? { failed: true } : { text } }); });\n return () => { current = false; };\n }, [artifact, key, context]);\n if (artifact && context && !context.read) return { failed: true };\n return loaded && loaded.key === key ? loaded.value : undefined;\n}\n\n/** The mockup itself: HTML and SVG in a sandboxed frame (scripts only, opaque origin, no network), Markdown drawn, anything else as text. */\nfunction MockupBody({ artifact, text, testId }: { readonly artifact: ArtifactDescriptor; readonly text: string; readonly testId: string }) {\n if (artifact.type === 'html' || artifact.type === 'svg') return <InteractiveFrame text={text} sources={[]} title={artifact.title} testId={testId} />;\n if (artifact.type === 'markdown') return <div data-testid={`${testId}-markdown`} className=\"min-h-0 flex-1 overflow-auto p-4 text-[15px]\"><Markdown text={text} /></div>;\n return <pre data-testid={`${testId}-text`} className=\"m-0 min-h-0 flex-1 overflow-auto p-4 text-xs whitespace-pre-wrap\">{text}</pre>;\n}\n\nconst descriptorOf = (artifact: ChatArtifactCardProps['artifact']): ArtifactDescriptor | undefined => {\n const descriptor = artifact.state === 'ready' ? artifact.descriptor as PresentedArtifact : undefined;\n return descriptor && descriptor.type !== 'task' ? descriptor : undefined;\n};\n\n/**\n * A presented mockup in the member thread (C7): one row per mockup, so two or three presented in a row read as a vertical list. A small\n * live thumbnail (the sandboxed page at phone width, scaled down), the title and « Ouvrir › »; the whole row (min 64px) opens it full height.\n */\nexport function PmArtifactCard({ artifact, open, onOpen }: ChatArtifactCardProps) {\n const context = useContext(MockupContext);\n const descriptor = descriptorOf(artifact);\n const loaded = useMockup(descriptor);\n const title = descriptor?.title ?? artifact.title ?? '';\n return <div data-testid=\"pm-mockup\" data-state={artifact.state} data-open={open ? 'true' : undefined} className=\"relative my-1 flex min-h-16 w-full max-w-sm items-center gap-3 rounded-pm-box bg-background p-2 ring-1 ring-border\">\n <div aria-hidden=\"true\" inert className=\"relative h-24 w-16 shrink-0 overflow-hidden rounded-pm-control bg-muted ring-1 ring-border\">\n {descriptor && loaded && 'text' in loaded && <div className=\"pointer-events-none absolute top-0 left-0 flex h-[585px] w-[390px] origin-top-left scale-[0.1641] flex-col\">\n <MockupBody artifact={descriptor} text={loaded.text} testId=\"pm-mockup-thumb\" />\n </div>}\n </div>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"pm-mockup-title\" className=\"truncate text-sm font-medium\">{artifact.state === 'ready' ? title : <Shimmer>{title}</Shimmer>}</span>\n {loaded && 'failed' in loaded ? <span className=\"text-[13px] text-muted-foreground\">{context?.text.mockupUnavailable}</span>\n : artifact.state === 'ready' && <span className=\"flex items-center text-[13px] text-muted-foreground\">{context?.text.openMockup}<ChevronRightIcon className=\"size-3.5\" aria-hidden=\"true\" /></span>}\n </span>\n {descriptor && onOpen && <Button variant=\"link\" data-testid=\"pm-mockup-open\" aria-label={`${context?.text.openMockup ?? ''} ${title}`} onClick={onOpen} className=\"absolute inset-0 h-auto w-full\" />}\n </div>;\n}\n\n/** The opened mockup, full height inside the sheet, under « ‹ Retour ». */\nexport function PmMockupView({ artifact, text, onBack }: { readonly artifact: ArtifactDescriptor; readonly text: PmText; readonly onBack: () => void }) {\n const loaded = useMockup(artifact);\n return <div data-testid=\"pm-mockup-view\" className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex shrink-0 items-center gap-1 py-1 pr-4 pl-2\">\n <Button variant=\"ghost\" data-testid=\"pm-mockup-back\" onClick={onBack} className=\"min-h-11\">{text.back}</Button>\n <span data-testid=\"pm-mockup-view-title\" className=\"min-w-0 flex-1 truncate text-right text-[13px] text-muted-foreground\">{artifact.title}</span>\n </div>\n {loaded && 'text' in loaded ? <MockupBody artifact={artifact} text={loaded.text} testId=\"pm-mockup-full\" />\n : <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{loaded ? text.mockupUnavailable : text.mockupLoading}</p>}\n </div>;\n}\n\n/** The panel's artifacts config when the host gives none: `present` results become `PmArtifactCard`s that open in the sheet. */\nexport function pmArtifacts(open: (artifact: ArtifactDescriptor) => void, shown: ArtifactDescriptor | undefined): ChatArtifactsConfig {\n return {\n open: ((artifact: PresentedArtifact) => { if (artifact.type !== 'task') open(artifact); }) as ChatArtifactsConfig['open'],\n isOpen: ((artifact: PresentedArtifact) => shown !== undefined && artifact.type !== 'task' && mockupKey(artifact) === mockupKey(shown)) as NonNullable<ChatArtifactsConfig['isOpen']>,\n recognise: recogniseArtifact, Card: PmArtifactCard,\n };\n}\n",
103
103
  "type": "registry:component",
104
104
  "target": "components/pm-panel/pm-mockups.tsx"
105
105
  }
@@ -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.36/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {