@hachej/boring-ui-kit 0.4.30 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +15 -15
  4. package/r/board-viewer.json +6 -6
  5. package/r/button.json +1 -1
  6. package/r/dock.json +1 -1
  7. package/r/documents.json +12 -12
  8. package/r/feedback-preview.json +1 -1
  9. package/r/feedback.json +6 -6
  10. package/r/file-tree.json +4 -4
  11. package/r/highlights.json +3 -3
  12. package/r/html-viewer.json +3 -3
  13. package/r/looks-viewer.json +2 -2
  14. package/r/maquette-viewer.json +1 -1
  15. package/r/markdown-editor.json +3 -3
  16. package/r/mockup-viewer.json +1 -1
  17. package/r/pi-ambient.json +8 -8
  18. package/r/pi-app-kinds.json +10 -10
  19. package/r/pi-app.json +10 -10
  20. package/r/pi-artifacts.json +2 -2
  21. package/r/pi-chat-approval.json +2 -2
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +2 -2
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +2 -2
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +3 -3
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +3 -3
  33. package/r/placed-agent.json +4 -4
  34. package/r/pm-panel.json +18 -12
  35. package/r/provider-setup.json +1 -1
  36. package/r/registry.json +184 -179
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +6 -6
  39. package/r/task-board.json +2 -2
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/task-mentions.json +4 -4
  42. package/r/version-history.json +2 -2
  43. package/r/viewer-code.json +2 -2
  44. package/r/viewer-frame.json +4 -4
  45. package/r/viewer-html.json +4 -4
  46. package/r/viewer-image.json +2 -2
  47. package/r/viewer-markdown.json +5 -5
  48. package/r/viewer-pdf.json +3 -3
  49. package/r/viewers.json +6 -6
  50. package/r/windows.json +1 -1
package/r/pm-panel.json CHANGED
@@ -4,21 +4,21 @@
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.30",
8
- "@hachej/boring-feedback@^0.4.30",
7
+ "@hachej/boring-ui-kit@^0.4.31",
8
+ "@hachej/boring-feedback@^0.4.31",
9
9
  "@earendil-works/pi-durable@~1.4.0",
10
10
  "lucide-react@^1.51.0",
11
11
  "react@^19.0.0",
12
12
  "react-dom@^19.0.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/feedback.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/task-board.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/task-mentions.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.30/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/feedback.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/task-board.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/task-mentions.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/utils.json"
22
22
  ],
23
23
  "files": [
24
24
  {
@@ -35,7 +35,7 @@
35
35
  },
36
36
  {
37
37
  "path": "registry/pm-panel/use-pm-panel.ts",
38
- "content": "'use client';\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';\nimport { 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}\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}\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}\nfunction headingOf(element: Element): string | undefined {\n const heading = element.querySelector('h1, h2, h3, h4, h5, h6, [role=heading]')?.textContent?.trim();\n return heading || 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 = '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 ?? headingOf(element) ?? 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",
38
+ "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}\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}\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",
39
39
  "type": "registry:hook",
40
40
  "target": "components/pm-panel/use-pm-panel.ts"
41
41
  },
@@ -75,15 +75,21 @@
75
75
  "type": "registry:component",
76
76
  "target": "components/pm-panel/pm-mentions.tsx"
77
77
  },
78
+ {
79
+ "path": "registry/pm-panel/pm-task-source.tsx",
80
+ "content": "'use client';\n\n// The one-line app's task source (spec 2 « Mes demandes » and C7's request card): the host's same-origin task API, behind a feature\n// check. This file is the one adapter between the host's records and `TaskRecord`: the host contract's field names change here only.\nimport { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { taskReference } from '@hachej/boring-ui-kit/task-board';\nimport { Button } from '@/components/ui/button';\nimport { TaskRequestCard } from '../task-board/task-board';\nimport type { ImproveSpec, StudioTaskBoardSeam } from './use-pm-panel';\nimport type { PmText } from './pm-text';\n\n/**\n * One task of `GET {tasks}?mine=1` (`{ tasks, cursor }`, hachej/boring-factory#892): the viewer's own requests for this app, as requester\n * or waiting on them. The D-ref is `ref` (list) or `number` (`tasks_create`), the update time `updated` or `updated_at`.\n */\nexport interface HostTask {\n readonly id: string;\n readonly ref?: string;\n readonly number?: string;\n readonly title: string;\n readonly state: string;\n /** Host wording, shown as is. */\n readonly status?: string;\n readonly created?: string | number;\n readonly updated?: string | number;\n readonly updated_at?: string | number;\n readonly waiting_on_you?: boolean;\n /** The question, when the task waits on the viewer. */\n readonly question?: string;\n}\n\nconst STATES: readonly TaskState[] = ['waiting', 'working', 'open', 'done', 'closed'];\nconst stamp = (value: string | number | undefined) => value === undefined ? undefined : new Date(value).toISOString();\n\n/** Every listed task is the viewer's (the host filtered them), so the viewer is their requester for « Mes demandes ». */\nexport function toTaskRecord(task: HostTask, viewer: string): TaskRecord {\n const waiting = task.waiting_on_you === true;\n const created = stamp(task.created), updated = stamp(task.updated ?? task.updated_at), ref = task.ref ?? task.number;\n return {\n id: task.id, ...(ref ? { number: ref } : {}), title: task.title, ...(task.status ? { status: task.status } : {}), requesterId: viewer, waiting_on_you: waiting,\n state: waiting ? 'waiting' : STATES.includes(task.state as TaskState) ? task.state as TaskState : 'open',\n ...(waiting ? { waiting_on: { who: viewer, type: 'person' as const, ...(task.question ? { question: task.question } : {}) } } : {}),\n ...(created ? { created_at: created } : {}), ...(updated ? { updated_at: updated } : {}),\n };\n}\n\n/** `null`: the host has no task source for this viewer (404 or 403), so « Mes demandes » stays disabled. */\nexport async function readTasks(url: string, viewer: string, signal?: AbortSignal): Promise<readonly TaskRecord[] | null> {\n const response = await fetch(`${url}?mine=1`, { credentials: 'same-origin', ...(signal ? { signal } : {}) });\n if (response.status === 404 || response.status === 403) return null;\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const body = await response.json() as { readonly tasks?: readonly HostTask[] };\n return (body.tasks ?? []).map(task => toTaskRecord(task, viewer));\n}\n\n/** Answers the question the task waits on, as the viewer. « Confirmer » is such an answer. */\nexport async function answerTask(url: string, id: string, text: string): Promise<void> {\n const response = await fetch(`${url}/${encodeURIComponent(id)}/answer`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n}\n\nconst REF = /\\bD-\\d+\\b/, UUID = /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b/i;\n/** A D-ref or task id in a tool result: `details` fields first (`ref`, `number`, `id`, nested `task`), then the text. */\nfunction referenceIn(value: unknown): { readonly ref?: string; readonly id?: string } | undefined {\n if (typeof value === 'string') { const ref = REF.exec(value)?.[0], id = UUID.exec(value)?.[0]; return ref || id ? { ...(ref ? { ref } : {}), ...(id ? { id } : {}) } : undefined; }\n if (!value || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n for (const key of ['ref', 'number', 'id', 'task']) { const found = record[key] === undefined ? undefined : referenceIn(record[key]); if (found) return found; }\n return undefined;\n}\n\n/** The PM's task tool (hachej/boring-factory#892): `content[0].text` is JSON `{ created, task: { id, number: 'D-n', title, state, status, updated_at } }`. */\nexport const TASK_CREATE_TOOL = 'tasks_create';\nfunction exactTask(message: { readonly toolName: string; readonly content: readonly { readonly type: string; readonly text?: string }[] }): { readonly ref?: string; readonly id?: string } | undefined {\n if (message.toolName !== TASK_CREATE_TOOL || message.content[0]?.type !== 'text') return undefined;\n try {\n const task = (JSON.parse(message.content[0].text ?? '') as { readonly task?: { readonly id?: unknown; readonly number?: unknown } }).task;\n const id = typeof task?.id === 'string' ? task.id : undefined, ref = typeof task?.number === 'string' ? task.number : undefined;\n return id || ref ? { ...(ref ? { ref } : {}), ...(id ? { id } : {}) } : undefined;\n } catch { return undefined; }\n}\n\n/**\n * The task a PM reply created, from a tool result run in the same turn before that reply: `tasks_create`'s JSON first; then, tolerant\n * (an older or renamed tool), any tool whose name contains `task` and whose result carries a D-ref or an id. Adjust here when it changes.\n */\nexport function createdTask(entries: readonly EntryRecord[], reply: EntryRecord): { readonly ref?: string; readonly id?: string } | undefined {\n const at = entries.findIndex(entry => entry.id === reply.id);\n for (let index = (at < 0 ? entries.length : at) - 1; index >= 0; index--) {\n for (const message of [...(entries[index]!.model ?? [])].reverse()) {\n if (message.role === 'user') return undefined;\n if (message.role !== 'toolResult' || message.isError || !/task/i.test(message.toolName)) continue;\n const found = exactTask(message) ?? referenceIn(message.details) ?? referenceIn(message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'));\n if (found) return found;\n }\n }\n return undefined;\n}\n\n/**\n * `spec.tasks` for the one line: read when `active`, again when the conversation grows (a PM reply may have created a task) and after an\n * answer. `undefined` until the host answers, and for good when it has no task source.\n */\nexport function useHostTasks(url: string | undefined, viewer: string | undefined, controller: ImproveSpec['controller'] | undefined, text: PmText): StudioTaskBoardSeam | undefined {\n const [tasks, setTasks] = useState<readonly TaskRecord[] | null>();\n const entries = useSyncExternalStore(controller?.subscribe ?? noSubscribe, () => controller?.getSnapshot().view?.entries.length ?? 0, () => 0);\n const [answered, setAnswered] = useState(0);\n const absent = tasks === null;\n useEffect(() => {\n if (!url || !viewer || absent) return;\n const abort = new AbortController();\n const timer = setTimeout(() => { readTasks(url, viewer, abort.signal).then(setTasks, () => undefined); }, 300);\n return () => { clearTimeout(timer); abort.abort(); };\n }, [url, viewer, absent, entries, answered]);\n const confirm = useCallback(async (task: TaskRecord) => {\n if (!url) return;\n setTasks(current => current?.map(each => each.id === task.id ? { ...each, state: 'working', waiting_on_you: false } : each));\n await answerTask(url, task.id, text.confirm).catch(() => undefined);\n setAnswered(count => count + 1);\n }, [url, text.confirm]);\n return useMemo(() => !tasks || !viewer ? undefined : {\n tasks: [...tasks], currentUser: viewer,\n // No task route in the one line: a card is the request, its answer happens in the PM chat.\n onOpen: () => undefined,\n renderRequest: ({ entry, edit }) => {\n const made = createdTask(controller?.getSnapshot().view?.entries ?? [], entry);\n const task = made && tasks.find(each => each.id === made.id || (made.ref !== undefined && taskReference(each) === made.ref));\n if (!task) return undefined;\n return <TaskRequestCard task={task} variant=\"compact\" locale={text.locale} viewerAction=\"confirmation\" actions={task.waiting_on_you ? <>\n <Button variant=\"outline\" data-testid=\"pm-request-modify\" onClick={() => edit(task.title)}>{text.modify}</Button>\n <Button data-testid=\"pm-request-confirm\" onClick={() => { void confirm(task); }}>{text.confirm}</Button>\n </> : undefined} />;\n },\n }, [tasks, viewer, text, confirm, controller]);\n}\nconst noSubscribe = () => () => undefined;\n",
81
+ "type": "registry:component",
82
+ "target": "components/pm-panel/pm-task-source.tsx"
83
+ },
78
84
  {
79
85
  "path": "registry/pm-panel/pm-text.ts",
80
- "content": "/** Spec 1 §9 and spec 2's strings, character for character: FR for the app, EN for Studio. */\nexport interface PmText {\n readonly locale: 'fr' | 'en';\n readonly placeholderBefore: string;\n readonly placeholderAfter: string;\n readonly hintIdle: string;\n readonly hintRec: string;\n /** The touch strip's C4 hint when the whole one would take more than two lines (spec 1 §2). */\n readonly hintRecShort: string;\n readonly mode: string;\n readonly select: string;\n readonly navigate: string;\n readonly done: string;\n readonly doneLabel: string;\n readonly speak: string;\n readonly recorder: string;\n readonly attach: string;\n readonly dictate: string;\n readonly send: string;\n readonly remove: string;\n readonly show: string;\n readonly showTitle: string;\n readonly meta: (seconds: number | undefined, captures: number) => string;\n readonly sentMeta: (seconds: number | undefined, captures: number) => string;\n readonly mentionHeader: (query: string) => string;\n readonly onYou: string;\n readonly added: (n: number, kind: string, label: string) => string;\n readonly removed: (n: number) => string;\n readonly title: string;\n readonly openPanel: string;\n readonly close: string;\n readonly review: string;\n readonly requests: string;\n readonly back: string;\n readonly subtitle: (app: string) => string;\n /** Click to comment (desktop, spec 1 §3b). */\n readonly commentOn: (kind: string, label: string) => string;\n readonly commentPlaceholder: string;\n readonly addToMessage: string;\n readonly sendToPm: string;\n readonly sentToPm: string;\n}\n\n/** `0:14`: whole seconds, never a fraction. */\nexport const clock = (seconds: number): string => {\n const whole = Math.max(0, Math.floor(seconds));\n return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;\n};\nconst captures = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;\n\nexport const pmTextFr: PmText = {\n locale: 'fr',\n placeholderBefore: 'Décrivez le problème, ou ', placeholderAfter: ' pour me le montrer.',\n hintIdle: 'Touchez ce que vous voulez montrer', hintRec: 'Parlez et touchez ce que vous montrez', hintRecShort: 'Parlez et touchez',\n mode: 'Mode', select: 'Sélectionner', navigate: 'Naviguer', done: 'Terminer', doneLabel: 'Terminer : relire dans le composer', speak: 'Parler',\n recorder: 'Ouvrir l’enregistreur', attach: 'Joindre un fichier', dictate: 'Dicter', send: 'Envoyer', remove: 'Retirer', show: 'Montrer', showTitle: 'Montrer',\n meta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · modifiable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TÂCHES · ${query}`, onYou: 'À vous',\n added: (n, kind, label) => `Élément ${n} ajouté : ${kind} · ${label}`, removed: n => `Élément ${n} retiré`,\n title: 'Améliorer l’app', openPanel: 'Ouvrir le panneau', close: 'Fermer', review: 'Relisez, puis envoyez', requests: 'Mes demandes', back: '‹ Retour',\n subtitle: app => `Le chef de projet · ${app}`,\n commentOn: (kind, label) => `Commenter ${kind} · ${label}`, commentPlaceholder: 'Votre commentaire…', addToMessage: 'Ajouter au message', sendToPm: 'Envoyer au PM', sentToPm: 'Envoyé au PM',\n};\n\nexport const pmTextEn: PmText = {\n locale: 'en',\n placeholderBefore: 'Describe the problem, or ', placeholderAfter: ' to show me',\n hintIdle: 'Tap what you want to show', hintRec: 'Speak and tap what you show', hintRecShort: 'Speak and tap',\n mode: 'Mode', select: 'Select', navigate: 'Navigate', done: 'Done', doneLabel: 'Done: reread in the composer', speak: 'Speak',\n recorder: 'Open the recorder', attach: 'Attach a file', dictate: 'Dictate', send: 'Send', remove: 'Remove', show: 'Montrer', showTitle: 'Montrer: point at the app',\n meta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · editable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TASKS · ${query}`, onYou: 'On you',\n added: (n, kind, label) => `Item ${n} added: ${kind} · ${label}`, removed: n => `Item ${n} removed`,\n title: 'Improve the app', openPanel: 'Open the panel', close: 'Close', review: 'Review, then send', requests: 'My requests', back: '‹ Back',\n subtitle: app => `The project lead · ${app}`,\n commentOn: (kind, label) => `Comment on ${kind} · ${label}`, commentPlaceholder: 'Your comment…', addToMessage: 'Add to message', sendToPm: 'Send to the PM', sentToPm: 'Sent to the PM',\n};\n\nexport const pmTextFor = (locale: string | undefined): PmText => locale?.startsWith('en') ? pmTextEn : pmTextFr;\n",
86
+ "content": "/** Spec 1 §9 and spec 2's strings, character for character: FR for the app, EN for Studio. */\nexport interface PmText {\n readonly locale: 'fr' | 'en';\n readonly placeholderBefore: string;\n readonly placeholderAfter: string;\n readonly hintIdle: string;\n readonly hintRec: string;\n /** The touch strip's C4 hint when the whole one would take more than two lines (spec 1 §2). */\n readonly hintRecShort: string;\n readonly mode: string;\n readonly select: string;\n readonly navigate: string;\n readonly done: string;\n readonly doneLabel: string;\n readonly speak: string;\n readonly recorder: string;\n readonly attach: string;\n readonly dictate: string;\n readonly send: string;\n readonly remove: string;\n readonly show: string;\n readonly showTitle: string;\n readonly meta: (seconds: number | undefined, captures: number) => string;\n readonly sentMeta: (seconds: number | undefined, captures: number) => string;\n readonly mentionHeader: (query: string) => string;\n readonly onYou: string;\n readonly added: (n: number, kind: string, label: string) => string;\n readonly removed: (n: number) => string;\n readonly title: string;\n readonly openPanel: string;\n readonly close: string;\n readonly review: string;\n readonly requests: string;\n readonly back: string;\n readonly subtitle: (app: string) => string;\n /** Click to comment (desktop, spec 1 §3b). */\n readonly commentOn: (kind: string, label: string) => string;\n readonly commentPlaceholder: string;\n readonly addToMessage: string;\n readonly sendToPm: string;\n readonly sentToPm: string;\n /** The request card's two answers (C7): « Modifier » goes back to the composer, « Confirmer » is the viewer's answer to the task. */\n readonly modify: string;\n readonly confirm: string;\n}\n\n/** `0:14`: whole seconds, never a fraction. */\nexport const clock = (seconds: number): string => {\n const whole = Math.max(0, Math.floor(seconds));\n return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;\n};\nconst captures = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;\n\nexport const pmTextFr: PmText = {\n locale: 'fr',\n placeholderBefore: 'Décrivez le problème, ou ', placeholderAfter: ' pour me le montrer.',\n hintIdle: 'Touchez ce que vous voulez montrer', hintRec: 'Parlez et touchez ce que vous montrez', hintRecShort: 'Parlez et touchez',\n mode: 'Mode', select: 'Sélectionner', navigate: 'Naviguer', done: 'Terminer', doneLabel: 'Terminer : relire dans le composer', speak: 'Parler',\n recorder: \"Ouvrir l'enregistreur\", attach: 'Joindre un fichier', dictate: 'Dicter', send: 'Envoyer', remove: 'Retirer', show: 'Montrer', showTitle: 'Montrer',\n meta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · modifiable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Écrit · ${captures(n, 'capture', 'captures')}` : `Dicté · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TÂCHES · ${query}`, onYou: 'À vous',\n added: (n, kind, label) => `Élément ${n} ajouté : ${kind} · ${label}`, removed: n => `Élément ${n} retiré`,\n title: \"Améliorer l'app\", openPanel: 'Ouvrir le panneau', close: 'Fermer', review: 'Relisez, puis envoyez', requests: 'Mes demandes', back: '‹ Retour',\n subtitle: app => `Le chef de projet · ${app}`,\n commentOn: (kind, label) => `Commenter ${kind} · ${label}`, commentPlaceholder: 'Votre commentaire…', addToMessage: 'Ajouter au message', sendToPm: 'Envoyer au PM', sentToPm: 'Envoyé au PM',\n modify: 'Modifier', confirm: 'Confirmer',\n};\n\nexport const pmTextEn: PmText = {\n locale: 'en',\n placeholderBefore: 'Describe the problem, or ', placeholderAfter: ' to show me',\n hintIdle: 'Tap what you want to show', hintRec: 'Speak and tap what you show', hintRecShort: 'Speak and tap',\n mode: 'Mode', select: 'Select', navigate: 'Navigate', done: 'Done', doneLabel: 'Done: reread in the composer', speak: 'Speak',\n recorder: 'Open the recorder', attach: 'Attach a file', dictate: 'Dictate', send: 'Send', remove: 'Remove', show: 'Montrer', showTitle: 'Montrer: point at the app',\n meta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')} · editable`,\n sentMeta: (seconds, n) => seconds === undefined ? `Written · ${captures(n, 'capture', 'captures')}` : `Dictated · ${clock(seconds)} · ${captures(n, 'capture', 'captures')}`,\n mentionHeader: query => `TASKS · ${query}`, onYou: 'On you',\n added: (n, kind, label) => `Item ${n} added: ${kind} · ${label}`, removed: n => `Item ${n} removed`,\n title: 'Improve the app', openPanel: 'Open the panel', close: 'Close', review: 'Review, then send', requests: 'My requests', back: '‹ Back',\n subtitle: app => `The project lead · ${app}`,\n commentOn: (kind, label) => `Comment on ${kind} · ${label}`, commentPlaceholder: 'Your comment…', addToMessage: 'Add to message', sendToPm: 'Send to the PM', sentToPm: 'Sent to the PM',\n modify: 'Modify', confirm: 'Confirm',\n};\n\nexport const pmTextFor = (locale: string | undefined): PmText => locale?.startsWith('en') ? pmTextEn : pmTextFr;\n",
81
87
  "type": "registry:lib",
82
88
  "target": "components/pm-panel/pm-text.ts"
83
89
  },
84
90
  {
85
91
  "path": "registry/pm-panel/use-improve-agent.ts",
86
- "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { useRemoteChat } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatIdentity } from '@hachej/boring-ui-kit/native-chat';\nimport type { ImproveSpec, PmTranscript } from './use-pm-panel';\n\n/**\n * The one line an app writes: `improve: { agent: 'pm' }`. The kit opens that agent's conversation through the app's own door\n * (hachej/boring-factory#854), same origin and as the signed-in person: `GET {base}/agent` (identity), `{base}/conversations`\n * (the viewer's latest, created when there is none) and `{base}/chat?conversation=` (the native chat wire). Dictation is the host's, not\n * the agent's: `POST /api/host/transcribe?language=` (audio body, answers `{ text, segments? }`).\n */\nexport interface ImproveAgent {\n readonly agent: string;\n /** Default `/api/{agent}`. */\n readonly base?: string;\n}\nexport type ImproveProp = ImproveSpec | ImproveAgent;\nexport const isImproveAgent = (value: ImproveProp | undefined): value is ImproveAgent => !!value && 'agent' in value && typeof value.agent === 'string' && !('controller' in value);\n\ntype Door = { readonly identity: ChatIdentity; readonly conversationId: string } | { readonly error: string };\nconst sameOrigin = (request: Request) => fetch(new Request(request, { credentials: 'same-origin' }));\n\nasync function openDoor(base: string, signal: AbortSignal): Promise<Door> {\n const json = async (response: Response) => { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); return response.json() as Promise<Record<string, unknown>>; };\n const descriptor = await json(await fetch(`${base}/agent`, { credentials: 'same-origin', signal }));\n const identity = (descriptor['identity'] ?? descriptor) as ChatIdentity;\n if (typeof identity?.runtimeId !== 'string' || typeof identity.scopeId !== 'string' || typeof identity.principalId !== 'string') throw new Error('The agent door returned no chat identity');\n const listed = await json(await fetch(`${base}/conversations`, { credentials: 'same-origin', signal }));\n const rows = (Array.isArray(listed['conversations']) ? listed['conversations'] : []) as { readonly id: string | number; readonly archived?: boolean; readonly updatedAt?: number }[];\n const latest = rows.filter(row => !row.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];\n if (latest) return { identity, conversationId: String(latest.id) };\n const created = await json(await fetch(`${base}/conversations?op=create`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}', signal }));\n return { identity, conversationId: String(created['conversationId']) };\n}\n\n/** Resolves `{ agent }` once `active` (the first open), then keeps the controller. `undefined` while connecting or offline (`problem`). */\nexport function useImproveAgent(value: ImproveProp | undefined, active: boolean, defaults: { readonly appName?: string; readonly locale?: string }): { readonly spec: ImproveSpec | undefined; readonly problem: string | undefined } {\n const agent = isImproveAgent(value) ? value : undefined;\n const base = agent ? agent.base ?? `/api/${encodeURIComponent(agent.agent)}` : undefined;\n const [door, setDoor] = useState<{ readonly base: string; readonly door: Door }>();\n const [requested, setRequested] = useState(false);\n if (active && agent && !requested) setRequested(true);\n useEffect(() => {\n if (!base || !requested) return;\n const abort = new AbortController();\n openDoor(base, abort.signal).then(result => setDoor({ base, door: result }), (error: unknown) => { if (!abort.signal.aborted) setDoor({ base, door: { error: error instanceof Error ? error.message : String(error) } }); });\n return () => abort.abort();\n }, [base, requested]);\n const open = door && door.base === base && 'identity' in door.door ? door.door : undefined;\n const remote = useRemoteChat({ conversationId: open?.conversationId, endpoint: id => new URL(`${base}/chat?conversation=${encodeURIComponent(String(id))}`, location.href), fetch: sameOrigin, identity: open?.identity });\n const ready = remote.status === 'ready' ? remote : undefined;\n const spec = useMemo<ImproveSpec | undefined>(() => !agent ? value as ImproveSpec | undefined : ready ? {\n controller: ready.controller, ...(defaults.appName ? { appName: defaults.appName } : {}),\n chatProps: { actions: ready.actions, showHistory: false, ...(defaults.locale ? { locale: defaults.locale as 'fr' } : {}) },\n pointVoice: { transcribe: async (audio: Blob, signal: AbortSignal): Promise<string | PmTranscript> => {\n const response = await fetch(`/api/host/transcribe${defaults.locale ? `?language=${encodeURIComponent(defaults.locale)}` : ''}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': audio.type || 'application/octet-stream' }, body: audio, signal });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const result = await response.json() as PmTranscript | { readonly text?: string };\n return typeof result.text === 'string' ? result as PmTranscript : '';\n } },\n } : undefined, [agent, value, ready, defaults.appName, defaults.locale]);\n const problem = door && 'error' in door.door ? door.door.error : remote.status === 'offline' ? remote.error : undefined;\n return { spec, problem };\n}\n",
92
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { useRemoteChat } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatIdentity } from '@hachej/boring-ui-kit/native-chat';\nimport type { ImproveSpec, PmTranscript } from './use-pm-panel';\nimport { useHostTasks } from './pm-task-source';\nimport { pmTextFor } from './pm-text';\n\n/**\n * The one line an app writes: `improve: { agent: 'pm' }`. The kit opens that agent's conversation through the app's own door\n * (hachej/boring-factory#854), same origin and as the signed-in person: `GET {base}/agent` (identity), `{base}/conversations`\n * (the viewer's latest, created when there is none) and `{base}/chat?conversation=` (the native chat wire). Dictation is the host's, not\n * the agent's: `POST api/host/transcribe?language=` (audio body, answers `{ text, segments? }`). The viewer's requests are the host's task\n * API, `_tasks/api` (see `pm-task-source.tsx`; « Mes demandes » stays disabled when it answers 404 or 403).\n * Every path is relative to the page, like the app's own `api()` calls: the host may serve the app under a prefix (`/apps/<app>/`,\n * `/preview/<sha>/`).\n */\nexport interface ImproveAgent {\n readonly agent: string;\n /** Default `api/{agent}`, relative to the page; resolved against `document.baseURI` (so `/api/pm` stays at the origin's root). */\n readonly base?: string;\n}\nexport type ImproveProp = ImproveSpec | ImproveAgent;\nexport const isImproveAgent = (value: ImproveProp | undefined): value is ImproveAgent => !!value && 'agent' in value && typeof value.agent === 'string' && !('controller' in value);\n\n/** A path of the app, resolved like the app's own requests: against the page (`document.baseURI`), never the origin's root. */\nconst appUrl = (path: string) => new URL(path, document.baseURI).href;\n\ntype Door = { readonly identity: ChatIdentity; readonly conversationId: string } | { readonly error: string };\nconst sameOrigin = (request: Request) => fetch(new Request(request, { credentials: 'same-origin' }));\n\nasync function openDoor(path: string, signal: AbortSignal): Promise<Door> {\n const json = async (response: Response) => { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); return response.json() as Promise<Record<string, unknown>>; };\n const base = appUrl(path);\n const descriptor = await json(await fetch(`${base}/agent`, { credentials: 'same-origin', signal }));\n const identity = (descriptor['identity'] ?? descriptor) as ChatIdentity;\n if (typeof identity?.runtimeId !== 'string' || typeof identity.scopeId !== 'string' || typeof identity.principalId !== 'string') throw new Error('The agent door returned no chat identity');\n const listed = await json(await fetch(`${base}/conversations`, { credentials: 'same-origin', signal }));\n const rows = (Array.isArray(listed['conversations']) ? listed['conversations'] : []) as { readonly id: string | number; readonly archived?: boolean; readonly updatedAt?: number }[];\n const latest = rows.filter(row => !row.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];\n if (latest) return { identity, conversationId: String(latest.id) };\n const created = await json(await fetch(`${base}/conversations?op=create`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}', signal }));\n return { identity, conversationId: String(created['conversationId']) };\n}\n\n/** Resolves `{ agent }` once `active` (the first open), then keeps the controller. `undefined` while connecting or offline (`problem`). */\nexport function useImproveAgent(value: ImproveProp | undefined, active: boolean, defaults: { readonly appName?: string; readonly locale?: string }): { readonly spec: ImproveSpec | undefined; readonly problem: string | undefined } {\n const agent = isImproveAgent(value) ? value : undefined;\n const base = agent ? agent.base ?? `api/${encodeURIComponent(agent.agent)}` : undefined;\n const [door, setDoor] = useState<{ readonly base: string; readonly door: Door }>();\n const [requested, setRequested] = useState(false);\n if (active && agent && !requested) setRequested(true);\n useEffect(() => {\n if (!base || !requested) return;\n const abort = new AbortController();\n openDoor(base, abort.signal).then(result => setDoor({ base, door: result }), (error: unknown) => { if (!abort.signal.aborted) setDoor({ base, door: { error: error instanceof Error ? error.message : String(error) } }); });\n return () => abort.abort();\n }, [base, requested]);\n const open = door && door.base === base && 'identity' in door.door ? door.door : undefined;\n const remote = useRemoteChat({ conversationId: open?.conversationId, endpoint: id => new URL(`${appUrl(base ?? '')}/chat?conversation=${encodeURIComponent(String(id))}`), fetch: sameOrigin, identity: open?.identity });\n const ready = remote.status === 'ready' ? remote : undefined;\n const tasks = useHostTasks(open ? appUrl('_tasks/api') : undefined, open?.identity.principalId, ready?.controller, pmTextFor(defaults.locale));\n const spec = useMemo<ImproveSpec | undefined>(() => !agent ? value as ImproveSpec | undefined : ready ? {\n controller: ready.controller, ...(defaults.appName ? { appName: defaults.appName } : {}), ...(tasks ? { tasks } : {}),\n chatProps: { actions: ready.actions, showHistory: false, ...(defaults.locale ? { locale: defaults.locale as 'fr' } : {}) },\n pointVoice: { transcribe: async (audio: Blob, signal: AbortSignal): Promise<string | PmTranscript> => {\n const response = await fetch(appUrl(`api/host/transcribe${defaults.locale ? `?language=${encodeURIComponent(defaults.locale)}` : ''}`), { method: 'POST', credentials: 'same-origin', headers: { 'content-type': audio.type || 'application/octet-stream' }, body: audio, signal });\n if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);\n const result = await response.json() as PmTranscript | { readonly text?: string };\n return typeof result.text === 'string' ? result as PmTranscript : '';\n } },\n } : undefined, [agent, value, ready, tasks, defaults.appName, defaults.locale]);\n const problem = door && 'error' in door.door ? door.door.error : remote.status === 'offline' ? remote.error : undefined;\n return { spec, problem };\n}\n",
87
93
  "type": "registry:hook",
88
94
  "target": "components/pm-panel/use-improve-agent.ts"
89
95
  }
@@ -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.30/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.31/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {