@hachej/boring-ui-kit 0.4.54 → 0.4.56

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 (49) hide show
  1. package/dist/chat-react-conversations.d.ts +7 -1
  2. package/dist/chat-react-conversations.js +52 -17
  3. package/package.json +2 -2
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +9 -9
  6. package/r/board-viewer.json +6 -6
  7. package/r/button.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/drawing-viewer.json +1 -1
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +4 -4
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +2 -2
  16. package/r/maquette-viewer.json +1 -1
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +9 -9
  20. package/r/pi-app-kinds.json +10 -10
  21. package/r/pi-app.json +10 -10
  22. package/r/pi-artifacts.json +2 -2
  23. package/r/pi-chat-approval.json +2 -2
  24. package/r/pi-chat-attachments.json +1 -1
  25. package/r/pi-chat-markdown.json +2 -2
  26. package/r/pi-chat-mentions.json +1 -1
  27. package/r/pi-chat-pickers.json +1 -1
  28. package/r/pi-chat-plugins.json +11 -11
  29. package/r/pi-chat-question.json +1 -1
  30. package/r/pi-chat-slash.json +2 -2
  31. package/r/pi-chat-subagent.json +1 -1
  32. package/r/pi-chat-voice.json +3 -3
  33. package/r/pi-chat.json +8 -8
  34. package/r/pi-workspace.json +3 -3
  35. package/r/placed-agent.json +4 -4
  36. package/r/provider-setup.json +1 -1
  37. package/r/registry.json +162 -162
  38. package/r/shared-chat.json +6 -6
  39. package/r/spaces.json +1 -1
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/version-history.json +2 -2
  42. package/r/viewer-code.json +2 -2
  43. package/r/viewer-frame.json +4 -4
  44. package/r/viewer-html.json +4 -4
  45. package/r/viewer-image.json +2 -2
  46. package/r/viewer-markdown.json +5 -5
  47. package/r/viewer-pdf.json +3 -3
  48. package/r/viewers.json +6 -6
  49. package/r/windows.json +1 -1
package/r/pi-chat.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Pi chat",
5
5
  "description": "A complete Tailwind chat over the borrowed native chat controller: composer, messages, one collapsible activity block per turn (tool calls and reasoning), message queue, history and feedback cards for the opt-in feedback tool (lists, and Show offers that the host page resolves). Chat only: no layout. Owned, editable source; no AI SDK. The question, approval, sub-agent and artifact cards are separate items (pi-chat-question, pi-chat-approval, pi-chat-subagent, pi-artifacts); without them a question or an approval shows a \"waiting for your answer\" notice, a sub-agent call is an ordinary tool step and no artifact is offered. The optional pi-ambient and pi-workspace blocks build on it.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.54",
8
- "@hachej/boring-files@^0.4.54",
7
+ "@hachej/boring-ui-kit@^0.4.56",
8
+ "@hachej/boring-files@^0.4.56",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "@base-ui/react@1.9.0",
@@ -18,8 +18,8 @@
18
18
  "zod@^4.6.5"
19
19
  ],
20
20
  "registryDependencies": [
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/utils.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/button.json"
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/utils.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/button.json"
23
23
  ],
24
24
  "files": [
25
25
  {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  {
38
38
  "path": "registry/pi-chat/composer.tsx",
39
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, kindMentionDraft, mentionedPaths, mentionKind, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery, writeKindMentionDraft } from '@hachej/boring-ui-kit/chat-text';\nimport type { PasteToFile, SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, SlashConfig } from './config';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * Where an image part is drawn from: its bytes as a data URL, or, for a part the host sent as a link (`data: ''` and a `url`, which the\n * remote chat has resolved against its endpoint), that link when it is http(s). `undefined`: nothing safe to draw.\n */\nexport function imageSource(image: { readonly mimeType: string; readonly data: string; readonly url?: unknown }): string | undefined {\n if (image.data !== '' || typeof image.url !== 'string') return thumbnail(image.mimeType, image.data);\n try {\n const link = new URL(image.url, globalThis.location?.href);\n return link.protocol === 'https:' || link.protocol === 'http:' ? link.href : undefined;\n } catch { return undefined; }\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback extends ChatSessionFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport type { PendingUpload } from '@hachej/boring-ui-kit/chat-react';\nimport type { PendingUpload, ChatSessionFeedback } from '@hachej/boring-ui-kit/chat-react';\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n /** The phone camera sheet (`attachments.Camera`, from `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly camera?: AttachmentsConfig['Camera'];\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Optional visual placeholder (for an inline icon); the textarea keeps its accessible label. */\n readonly placeholderContent?: ReactNode;\n /** While the `@` picker is open, mark the typed query (`@d-2`) as an inline token behind the text (`data-slot=\"composer-mention-query\"`). */\n readonly markMentionQuery?: boolean;\n /** An optional host input editor; normal textarea/IME behavior is unchanged when omitted. */\n readonly input?: ReactNode;\n readonly renderInput?: ((input: { readonly text: string; readonly onText: (text: string) => void; readonly onCaret: (offset: number) => void; readonly onSend: () => void }) => ReactNode) | undefined;\n /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** Host controls at the very left edge of the bar, before \"+\" (`PiChat`'s `composerStart`). */\n readonly start?: ReactNode;\n /** A control shown in place of Send while there is nothing to send (WhatsApp's microphone): Send comes back as soon as there is text or a file. */\n readonly whenEmpty?: ReactNode;\n /** An active capture replaces the editable input and ordinary controls with this one recording row. */\n readonly recording?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-md:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a menu left open when the composer gets disabled closes once, after commit\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/** Draws the text with `start..end` wrapped in a mark, at the textarea's exact font, padding and scroll, under its (transparent) background. */\nfunction QueryBackdrop({ text, start, end }: { readonly text: string; readonly start: number; readonly end: number }) {\n const layer = useRef<HTMLDivElement>(null);\n useLayoutEffect(() => {\n const node = layer.current, field = node?.parentElement?.querySelector('textarea');\n if (!node || !field) return;\n const copy = () => {\n const style = getComputedStyle(field);\n for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textIndent', 'wordSpacing'] as const) node.style[name] = style[name];\n node.style.top = `${field.offsetTop}px`; node.style.left = `${field.offsetLeft}px`; node.style.width = `${field.clientWidth}px`; node.style.height = `${field.clientHeight}px`;\n node.scrollTop = field.scrollTop;\n };\n copy();\n field.addEventListener('scroll', copy);\n return () => field.removeEventListener('scroll', copy);\n });\n return <div ref={layer} aria-hidden=\"true\" data-slot=\"composer-mention-backdrop\" className=\"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\">\n {text.slice(0, start)}<mark data-slot=\"composer-mention-query\" className=\"bg-muted text-transparent\">{text.slice(start, end)}</mark>{text.slice(end)}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Hands the textarea to the caller's ref, a callback or an object. */\nfunction assignRef(ref: Ref<HTMLTextAreaElement>, element: HTMLTextAreaElement | null) {\n if (typeof ref === 'function') ref(element); else if (ref) (ref as { current: HTMLTextAreaElement | null }).current = element;\n}\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach, layout = 'card' } = props;\n const draft = kindMentionDraft(props.text, props.mentions);\n const text = draft.text;\n const tokens = useRef(draft.tokens);\n useLayoutEffect(() => { tokens.current = draft.tokens; });\n // An asynchronous slash command must use today's tokens, not restore a chip removed since it began.\n const onText = (next: string) => props.onText(writeKindMentionDraft(next, tokens.current, props.slash?.skills?.map(skill => skill.name)));\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- new text re-arms a dismissed menu once, after commit; the updater keeps the value when nothing changed\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n // A feature shows only when its config carries the component that draws it (`pi-chat-slash`, `pi-chat-mentions`, `pi-chat-attachments`).\n const Camera = props.camera, Slash = props.slash?.Picker, Mention = props.mentions?.Picker;\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept) && Camera !== undefined;\n const slash = Slash ? props.slash : undefined, mentions = Mention ? props.mentions : undefined;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const recording = Boolean(props.recording);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n assignRef(props.textareaRef, element);\n };\n useLayoutEffect(() => {\n if (recording) return;\n const element = inner.current;\n if (!element) return;\n const resize = () => {\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, [text, recording]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = [...new Set([...(props.mentionPaths ?? []), ...mentionedPaths(props.text).filter(path => mentionKind(path, props.mentions))])].filter(path => hasMention(props.text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = props.text.trim().length > 0 || attachments.length > 0 || props.hasFiles || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled && !props.recording;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); return; }\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => {\n const registered = mentionKind(path, props.mentions);\n const label = registered ? registered.kind.label(registered.path) : props.mentions?.label?.(path) ?? path;\n return <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={label} className=\"max-w-full md:max-w-[16rem]\"\n media={registered?.kind.icon ?? props.mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={registered?.kind.open ? <Button variant=\"link\" size=\"sm\" onClick={() => registered.kind.open?.(registered.path)} className=\"min-w-0 max-w-full justify-start truncate\">{label}</Button> : label}\n actions={<RemoveChip label={labels.remove(label)} onClick={() => props.onText(removeMention(props.text, path))} />} />;\n })}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full md:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full md:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholderContent ? '' : props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={cn(props.markMentionQuery && 'relative', pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11')} />;\n // The query mark is a backdrop with the textarea's own metrics behind its transparent text: the text the person edits stays the textarea's.\n const marked = props.markMentionQuery && mentionOpen && trigger ? <QueryBackdrop text={text} start={trigger.start} end={trigger.end} /> : null;\n // eslint-disable-next-line react-hooks/refs -- renderInput receives event callbacks, not ref values; an input calls them only on interaction\n const input = props.renderInput?.({ text, onText, onCaret: setCaret, onSend: () => { if (canSubmit && !menuOpen) props.onSend(); } }) ?? props.input ?? (props.placeholderContent || marked ? <div data-slot=\"composer-field\" className=\"relative flex min-h-16 w-full min-w-0 flex-1\">{marked}{textareaEl}{!text && props.placeholderContent && <span aria-hidden=\"true\" data-slot=\"composer-placeholder\" className=\"pointer-events-none absolute inset-x-3 top-2 text-sm text-muted-foreground\">{props.placeholderContent}</span>}</div> : textareaEl);\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n // One child slot either way, so a composer without `start` keeps its exact markup (React ids included).\n const startAndPlus = props.start ? <><div data-testid=\"composer-start\" className=\"flex shrink-0 items-center gap-1\">{props.start}</div>{plus}</> : plus;\n const fileEl = canAttach && <Input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={disabled || stopRequested} onClick={props.onStop} className=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : props.whenEmpty && !hasContent ? <div data-testid=\"composer-when-empty\" className=\"flex shrink-0 items-center\">{props.whenEmpty}</div>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex min-w-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && Slash && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && Mention && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} header={typeof mentions.pickerHeader === 'function' ? mentions.pickerHeader(trigger!.query) : mentions.pickerHeader} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && !props.recording && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {props.recording ? <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\" className=\"w-full\">{props.recording}</InputGroupAddon>\n </InputGroup> : pill ? <InputGroup>\n <InputGroupAddon>{startAndPlus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {input}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {input}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {startAndPlus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && Camera && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
39
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ChangeEvent, ClipboardEvent, ComponentProps, DragEvent, KeyboardEvent, ReactNode, Ref } from 'react';\nimport { AlertCircleIcon, FileTextIcon, Loader2Icon, MessageSquarePlusIcon, SlashIcon, XIcon } from 'lucide-react';\nimport { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentMedia, AttachmentTitle } from '@/components/ui/attachment';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupTextarea } from '@/components/ui/input-group';\nimport { Toggle } from '@/components/ui/toggle';\nimport { hasMention, isLongPaste, kindMentionDraft, mentionedPaths, mentionKind, mentionToken, mentionTrigger, removeMention, slashItems, slashQuery, writeKindMentionDraft } from '@hachej/boring-ui-kit/chat-text';\nimport type { PasteToFile, SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { AttachmentsConfig, MentionsConfig, PastedText, SlashConfig } from './config';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\n\nexport interface ComposerAttachment { readonly id: string; readonly name: string; readonly mimeType: string; readonly data: string }\n\nconst SAFE_IMAGE = /^image\\/(png|jpe?g|gif|webp)$/;\nconst BASE64 = /^[A-Za-z0-9+/=\\s]+$/;\n/** A data URL for a thumbnail, only for common image types and well-formed base64. */\nexport function thumbnail(mimeType: string, data: string): string | undefined {\n return SAFE_IMAGE.test(mimeType) && BASE64.test(data) ? `data:${mimeType};base64,${data}` : undefined;\n}\n\n/**\n * Where an image part is drawn from: its bytes as a data URL, or, for a part the host sent as a link (`data: ''` and a `url`, which the\n * remote chat has resolved against its endpoint), that link when it is http(s). `undefined`: nothing safe to draw.\n */\nexport function imageSource(image: { readonly mimeType: string; readonly data: string; readonly url?: unknown }): string | undefined {\n if (image.data !== '' || typeof image.url !== 'string') return thumbnail(image.mimeType, image.data);\n try {\n const link = new URL(image.url, globalThis.location?.href);\n return link.protocol === 'https:' || link.protocol === 'http:' ? link.href : undefined;\n } catch { return undefined; }\n}\n\n/**\n * The optional annotate toggle (FEEDBACK.md, \"UX\"): one button in the composer's row that arms element targeting on the host's page,\n * and one count chip for the notes taken. Send attaches them. The host owns everything else (the registry `feedback` item's\n * `useComposerFeedback` returns this shape). Absent: the composer is unchanged.\n */\nexport interface ComposerFeedback extends ChatSessionFeedback {\n /** The annotate toggle: arms targeting on the page (point mode on touch and compact screens), or stops it when it is on. */\n readonly annotate: () => void;\n /** Annotating is on: the toggle shows pressed. */\n readonly active: boolean;\n /** Notes taken and waiting to be sent with the next message; above zero, Send is allowed with no text. */\n readonly count: number;\n /** Removes the notes (the count chip's remove button). */\n readonly clear: () => void;\n\n}\n\n/** One file being uploaded (or that failed to upload), shown as a chip until it settles or is dismissed. */\nexport type { PendingUpload } from '@hachej/boring-ui-kit/chat-react';\nimport type { PendingUpload, ChatSessionFeedback } from '@hachej/boring-ui-kit/chat-react';\n\nexport interface ComposerProps {\n /** `/` menu of commands and skills. Absent: no menu and no keyboard trigger. */\n readonly slash?: SlashConfig | undefined;\n /** Reports a failing command. */\n readonly onSlashError?: ((cause: unknown) => void) | undefined;\n /** `@` file picker. Absent: no picker. */\n readonly mentions?: MentionsConfig | undefined;\n /** Paths picked or uploaded as mentions; each shows as a chip while its `@path` is still in the text. */\n readonly mentionPaths?: readonly string[];\n readonly onMentionPicked?: ((path: string) => void) | undefined;\n readonly uploads?: readonly PendingUpload[];\n readonly onDismissUpload?: ((id: string) => void) | undefined;\n /** A paste above `pasteToFile`'s limits, taken by the composer: the host uploads it as a file. Absent: pastes stay inline. */\n readonly onPasteText?: ((paste: PastedText) => void) | undefined;\n readonly pasteToFile?: PasteToFile | undefined;\n /** Extra controls in the bar (the model and effort pill) and a short note beside them. */\n readonly barStart?: ReactNode;\n readonly barNote?: ReactNode;\n /** Optional host mode/model chips beside the attach control. */\n readonly chips?: ReactNode;\n /** Optional host context above the input. */\n readonly quote?: ReactNode;\n readonly text: string;\n readonly onText: (text: string) => void;\n readonly attachments: readonly ComposerAttachment[];\n /** Host-owned content contributes to send readiness. */\n readonly hasFiles?: boolean;\n readonly onRemoveAttachment: (id: string) => void;\n /** The agent is working: the primary button stops it and Enter queues the message (steering is chosen on the queued message). */\n readonly working: boolean;\n /** A send would be refused right now (not connected, a submission in flight, uploading). */\n readonly sendBlocked: boolean;\n readonly disabled: boolean;\n /** Stop still works while `disabled`: the composer is held, the running reply is still the person's to stop. */\n readonly stopOpen?: boolean | undefined;\n readonly stopRequested: boolean;\n readonly onSend: () => void;\n readonly onStop: () => void;\n readonly uploading: boolean;\n readonly canAttach: boolean;\n /** The phone camera sheet (`attachments.Camera`, from `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly camera?: AttachmentsConfig['Camera'];\n readonly fileAccept: string;\n readonly onPickFiles: (files: readonly File[]) => void;\n readonly onKeyDown?: ((event: KeyboardEvent<HTMLTextAreaElement>) => void) | undefined;\n readonly textareaRef: Ref<HTMLTextAreaElement>;\n readonly placeholder?: string;\n /** Optional visual placeholder (for an inline icon); the textarea keeps its accessible label. */\n readonly placeholderContent?: ReactNode;\n /** While the `@` picker is open, mark the typed query (`@d-2`) as an inline token behind the text (`data-slot=\"composer-mention-query\"`). */\n readonly markMentionQuery?: boolean;\n /** An optional host input editor; normal textarea/IME behavior is unchanged when omitted. */\n readonly input?: ReactNode;\n readonly renderInput?: ((input: { readonly text: string; readonly onText: (text: string) => void; readonly onCaret: (offset: number) => void; readonly onSend: () => void }) => ReactNode) | undefined;\n /** `card` (default): chips, then full-width text, then a row of controls, in the host's shadcn InputGroup. `pill` puts the text between\n * the controls on one row. `stacked` and `inline` are compatibility values that render as `card`. */\n readonly layout?: 'card' | 'pill' | 'stacked' | 'inline';\n /** Host controls (for example a voice button) as round buttons between the model pill and the send button. */\n readonly barEnd?: ReactNode;\n /** Host controls at the very left edge of the bar, before \"+\" (`PiChat`'s `composerStart`). */\n readonly start?: ReactNode;\n /** A control shown in place of Send while there is nothing to send (WhatsApp's microphone): Send comes back as soon as there is text or a file. */\n readonly whenEmpty?: ReactNode;\n /** An active capture replaces the editable input and ordinary controls with this one recording row. */\n readonly recording?: ReactNode;\n /** The optional Feedback button and chip. */\n readonly feedback?: ComposerFeedback | undefined;\n}\n\n/** The annotate toggle beside the \"+\" menu in both layouts. */\nfunction AnnotateButton({ feedback, disabled }: { readonly feedback: ComposerFeedback; readonly disabled: boolean }) {\n const { labels } = useChatText();\n return <Toggle className=\"min-h-11 min-w-11\" data-testid=\"composer-annotate\" pressed={feedback.active} disabled={disabled}\n aria-label={labels.annotate} title={labels.annotateHint} onPressedChange={() => feedback.annotate()}>\n <MessageSquarePlusIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Toggle>;\n}\n\n/**\n * One chip above the text (a mention, an upload, an attachment or the notes count) as the host's shadcn Attachment. The remove\n * buttons keep a 44px touch target on coarse pointers; negative margins let it extend beyond the chip instead of growing it.\n */\nfunction Chip({ media, image = false, label, actions, ...props }: ComponentProps<typeof Attachment> & { readonly media: ReactNode; readonly image?: boolean; readonly label: ReactNode; readonly actions?: ReactNode }) {\n return <Attachment size=\"xs\" role=\"listitem\" {...props}>\n <AttachmentMedia variant={image ? 'image' : 'icon'}>{media}</AttachmentMedia>\n <AttachmentContent><AttachmentTitle>{label}</AttachmentTitle></AttachmentContent>\n {actions && <AttachmentActions className=\"max-md:-my-2.5 pointer-coarse:-my-2.5\">{actions}</AttachmentActions>}\n </Attachment>;\n}\n\nfunction RemoveChip({ label, onClick }: { readonly label: string; readonly onClick: () => void }) {\n return <AttachmentAction aria-label={label} title={label} onClick={onClick}><XIcon aria-hidden=\"true\" /></AttachmentAction>;\n}\n\n/** The count chip: how many notes go with the next message, and a way to drop them. */\nfunction NotesChip({ feedback }: { readonly feedback: ComposerFeedback }) {\n const { labels } = useChatText();\n return <Chip data-testid=\"feedback-count\" data-count={feedback.count} media={<MessageSquarePlusIcon aria-hidden=\"true\" />} label={labels.annotateNotes(feedback.count)}\n actions={<AttachmentAction data-testid=\"feedback-count-clear\" aria-label={labels.annotateClear} title={labels.annotateClear} onClick={() => feedback.clear()}><XIcon aria-hidden=\"true\" /></AttachmentAction>} />;\n}\n\n/** Whether a file input's `accept` admits a JPEG photo (empty admits everything). */\nexport function acceptsPhotos(accept: string): boolean {\n const tokens = accept.split(',').map(token => token.trim().toLowerCase()).filter(Boolean);\n return tokens.length === 0 || tokens.some(token => token === 'image/*' || token === '*/*' || token === 'image/jpeg' || token === '.jpg' || token === '.jpeg');\n}\n\n/** True on a touch-first device (a phone or tablet); false on the server, so the menu renders the same there. */\nfunction useCoarsePointer(): boolean {\n const [coarse, setCoarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)').matches ?? false);\n useEffect(() => {\n const query = globalThis.matchMedia?.('(pointer: coarse)');\n if (!query) return;\n const update = () => setCoarse(query.matches);\n update();\n query.addEventListener?.('change', update);\n return () => query.removeEventListener?.('change', update);\n }, []);\n return coarse;\n}\n\n/** The \"+\" button and the host's shadcn dropdown menu (Attach files, Take a photo, Commands). */\nfunction PlusActions({ disabled, onAttach, onTakePhoto, onCommands, textarea }: { readonly disabled: boolean; readonly onAttach?: (() => void) | undefined; readonly onTakePhoto?: (() => void) | undefined; readonly onCommands?: (() => void) | undefined; readonly textarea: { readonly current: HTMLTextAreaElement | null } }) {\n const { labels, icons } = useChatText();\n const [open, setOpen] = useState(false);\n // Commands puts `/` in the message: focus goes back to the text, not to the \"+\" button.\n const toText = useRef(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a menu left open when the composer gets disabled closes once, after commit\n useEffect(() => { if (disabled) setOpen(false); }, [disabled]);\n return <DropdownMenu open={open} onOpenChange={setOpen}>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" size=\"icon\" />} data-testid=\"composer-plus\" aria-label={labels.add} title={labels.add} disabled={disabled}>\n <icons.add data-icon=\"inline-start\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent side=\"top\" align=\"start\" aria-label={labels.add} data-testid=\"composer-plus-menu\" className=\"w-52\"\n finalFocus={() => { const text = toText.current; toText.current = false; return text ? textarea.current : true; }}>\n {onAttach && <DropdownMenuItem data-testid=\"composer-attach\" onClick={onAttach} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.attach aria-hidden=\"true\" />{labels.attachFiles}</DropdownMenuItem>}\n {onTakePhoto && <DropdownMenuItem data-testid=\"composer-take-photo\" onClick={onTakePhoto} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <icons.camera aria-hidden=\"true\" />{labels.takePhoto}</DropdownMenuItem>}\n {onCommands && <DropdownMenuItem data-testid=\"composer-commands\" onClick={() => { toText.current = true; onCommands(); }} className=\"max-md:min-h-11 pointer-coarse:min-h-11\">\n <SlashIcon aria-hidden=\"true\" />{labels.commands}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n\n/** Draws the text with `start..end` wrapped in a mark, at the textarea's exact font, padding and scroll, under its (transparent) background. */\nfunction QueryBackdrop({ text, start, end }: { readonly text: string; readonly start: number; readonly end: number }) {\n const layer = useRef<HTMLDivElement>(null);\n useLayoutEffect(() => {\n const node = layer.current, field = node?.parentElement?.querySelector('textarea');\n if (!node || !field) return;\n const copy = () => {\n const style = getComputedStyle(field);\n for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textIndent', 'wordSpacing'] as const) node.style[name] = style[name];\n node.style.top = `${field.offsetTop}px`; node.style.left = `${field.offsetLeft}px`; node.style.width = `${field.clientWidth}px`; node.style.height = `${field.clientHeight}px`;\n node.scrollTop = field.scrollTop;\n };\n copy();\n field.addEventListener('scroll', copy);\n return () => field.removeEventListener('scroll', copy);\n });\n return <div ref={layer} aria-hidden=\"true\" data-slot=\"composer-mention-backdrop\" className=\"pointer-events-none absolute overflow-hidden whitespace-pre-wrap break-words text-transparent\">\n {text.slice(0, start)}<mark data-slot=\"composer-mention-query\" className=\"bg-muted text-transparent\">{text.slice(start, end)}</mark>{text.slice(end)}\n </div>;\n}\n\nconst MAX_HEIGHT = 208;\n\n/** Hands the textarea to the caller's ref, a callback or an object. */\nfunction assignRef(ref: Ref<HTMLTextAreaElement>, element: HTMLTextAreaElement | null) {\n if (typeof ref === 'function') ref(element); else if (ref) (ref as { current: HTMLTextAreaElement | null }).current = element;\n}\n\n/** Prompt composer: auto-growing textarea, Enter to send, one primary Send/Stop button. While the agent works a message is queued. */\nexport function Composer(props: ComposerProps) {\n const { attachments, working, sendBlocked, disabled, stopRequested, uploading, canAttach, layout = 'card' } = props;\n const draft = kindMentionDraft(props.text, props.mentions);\n const text = draft.text;\n const tokens = useRef(draft.tokens);\n useLayoutEffect(() => { tokens.current = draft.tokens; });\n // An asynchronous slash command must use today's tokens, not restore a chip removed since it began.\n const onText = (next: string) => props.onText(writeKindMentionDraft(next, tokens.current, props.slash?.skills?.map(skill => skill.name)));\n const { labels, icons } = useChatText();\n const inner = useRef<HTMLTextAreaElement | null>(null);\n const composing = useRef(false);\n const [dragging, setDragging] = useState(false);\n const [caret, setCaret] = useState(0);\n // A menu closed with Escape stays closed until the text changes.\n const [dismissed, setDismissed] = useState<string | null>(null);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- new text re-arms a dismissed menu once, after commit; the updater keeps the value when nothing changed\n useEffect(() => { setDismissed(value => value === null || value === text ? value : null); }, [text]);\n const pendingCaret = useRef<number | null>(null);\n const fileInput = useRef<HTMLInputElement>(null);\n // The camera sheet lives here, not in the menu: the menu content unmounts as soon as it closes.\n const [cameraOpen, setCameraOpen] = useState(false);\n const coarse = useCoarsePointer();\n // A feature shows only when its config carries the component that draws it (`pi-chat-slash`, `pi-chat-mentions`, `pi-chat-attachments`).\n const Camera = props.camera, Slash = props.slash?.Picker, Mention = props.mentions?.Picker;\n const canPhoto = canAttach && coarse && acceptsPhotos(props.fileAccept) && Camera !== undefined;\n const slash = Slash ? props.slash : undefined, mentions = Mention ? props.mentions : undefined;\n const items = useMemo<SlashItem[]>(() => slash ? slashItems(slash) : [], [slash]);\n const recording = Boolean(props.recording);\n const setRef = (element: HTMLTextAreaElement | null) => {\n inner.current = element;\n assignRef(props.textareaRef, element);\n };\n useLayoutEffect(() => {\n if (recording) return;\n const element = inner.current;\n if (!element) return;\n const resize = () => {\n element.style.height = 'auto';\n element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT)}px`;\n element.style.overflowY = element.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden';\n };\n resize();\n if (pendingCaret.current !== null) { element.setSelectionRange(pendingCaret.current, pendingCaret.current); element.focus(); pendingCaret.current = null; }\n if (typeof ResizeObserver === 'undefined') return;\n let width = element.clientWidth;\n const observer = new ResizeObserver(() => {\n const next = element.clientWidth;\n if (next === width) return;\n width = next; resize();\n });\n observer.observe(element);\n return () => observer.disconnect();\n }, [text, recording]);\n\n const query = slash && items.length ? slashQuery(text, caret) : null;\n const trigger = query === null && mentions ? mentionTrigger(text, caret) : null;\n const slashOpen = query !== null && dismissed !== text, mentionOpen = trigger !== null && dismissed !== text;\n const menuOpen = slashOpen || mentionOpen;\n const edit = (next: string, position: number) => { pendingCaret.current = position; setCaret(position); onText(next); };\n const trackCaret = (element: HTMLTextAreaElement) => setCaret(element.selectionStart ?? element.value.length);\n const dismiss = () => setDismissed(text);\n const pickSlash = (item: SlashItem) => {\n const rest = text.slice(caret).replace(/^\\s+/, '');\n if (item.source === 'skill') return edit(`/${item.name} ${rest}`, item.name.length + 2);\n const command = slash?.commands?.find(candidate => candidate.name === item.name);\n onText(rest); setCaret(0);\n inner.current?.focus();\n if (!command) return;\n try { void Promise.resolve(command.run({ text: rest, setText: value => onText(value) })).catch(cause => props.onSlashError?.(cause)); }\n catch (cause) { props.onSlashError?.(cause); }\n };\n const pickMention = (path: string) => {\n if (!trigger) return;\n const token = `${mentionToken(path)} `;\n edit(`${text.slice(0, trigger.start)}${token}${text.slice(trigger.end)}`, trigger.start + token.length);\n props.onMentionPicked?.(path);\n };\n const openCommands = () => edit(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}/`, text.length + (text && !/\\s$/.test(text) ? 2 : 1));\n const chips = [...new Set([...(props.mentionPaths ?? []), ...mentionedPaths(props.text).filter(path => mentionKind(path, props.mentions))])].filter(path => hasMention(props.text, path));\n const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => { trackCaret(event.currentTarget); onText(event.currentTarget.value); };\n\n const { feedback } = props;\n const hasContent = props.text.trim().length > 0 || attachments.length > 0 || props.hasFiles || (feedback?.count ?? 0) > 0;\n const canSubmit = hasContent && !sendBlocked && !disabled && !props.recording;\n const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {\n props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n // Ctrl+Z right after a paste that is still uploading puts the pasted text back, instead of undoing the paste in the textarea.\n const undo = (props.uploads ?? []).filter(item => item.state === 'uploading' && item.cancel && item.restoreWhile === props.text).pop();\n if (undo && (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'z') { event.preventDefault(); undo.cancel!(); return; }\n // Never send while an input method editor is composing (Enter confirms the candidate).\n // A phone keyboard has no Shift+Enter: on a touch device Enter inserts a newline and the Send button sends.\n const touch = globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n if (event.key === 'Enter' && !touch && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n event.preventDefault();\n if (canSubmit) props.onSend();\n }\n };\n const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {\n if (canAttach && event.clipboardData.files.length) { event.preventDefault(); props.onPickFiles(Array.from(event.clipboardData.files)); return; }\n if (!props.onPasteText) return;\n const pasted = event.clipboardData.getData('text/plain');\n if (!isLongPaste(pasted, props.pasteToFile)) return;\n event.preventDefault();\n props.onPasteText({ text: pasted, start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd });\n };\n const drag = (event: DragEvent) => { if (canAttach) { event.preventDefault(); setDragging(event.type === 'dragover'); } };\n const drop = (event: DragEvent) => { if (!canAttach) return; event.preventDefault(); setDragging(false); props.onPickFiles(Array.from(event.dataTransfer.files)); };\n\n const chipList = (props.quote || chips.length > 0 || (props.uploads?.length ?? 0) > 0 || (feedback?.count ?? 0) > 0 || attachments.length > 0) && <>\n {props.quote}\n {feedback && feedback.count > 0 && <div role=\"list\" className=\"contents\"><NotesChip feedback={feedback} /></div>}\n {chips.length > 0 && <div role=\"list\" data-testid=\"mention-chips\" aria-label={labels.mentionedFiles} className=\"contents\">\n {chips.map(path => {\n const registered = mentionKind(path, props.mentions);\n const label = registered ? registered.kind.label(registered.path) : props.mentions?.label?.(path) ?? path;\n return <Chip key={path} data-testid=\"mention-chip\" data-path={path} title={label} className=\"max-w-full md:max-w-[16rem]\"\n media={registered?.kind.icon ?? props.mentions?.icon ?? <FileTextIcon aria-hidden=\"true\" />} label={registered?.kind.open ? <Button variant=\"link\" size=\"sm\" onClick={() => registered.kind.open?.(registered.path)} className=\"min-w-0 max-w-full justify-start truncate\">{label}</Button> : label}\n actions={<RemoveChip label={labels.remove(label)} onClick={() => props.onText(removeMention(props.text, path))} />} />;\n })}\n </div>}\n {(props.uploads?.length ?? 0) > 0 && <div role=\"list\" data-testid=\"uploads\" aria-label={labels.uploads} className=\"contents\">\n {props.uploads!.map(item => <Chip key={item.id} data-testid=\"upload-chip\" state={item.state === 'failed' ? 'error' : 'uploading'} data-state={item.state} title={item.error ?? item.name} className=\"max-w-full md:max-w-[20rem]\"\n media={item.state === 'failed' ? <AlertCircleIcon className=\"text-destructive\" aria-hidden=\"true\" /> : <Loader2Icon className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />}\n label={item.state === 'failed' ? labels.uploadFailed(item.name, item.error) : labels.uploading(item.name)}\n actions={<>\n {item.cancel && <RemoveChip label={labels.restorePaste} onClick={() => item.cancel!()} />}\n {item.state === 'failed' && <RemoveChip label={labels.dismiss(item.name)} onClick={() => props.onDismissUpload?.(item.id)} />}\n </>} />)}\n </div>}\n {attachments.length > 0 && <div role=\"list\" data-testid=\"attachments\" aria-label={labels.attachments} className=\"contents\">\n {attachments.map(attachment => {\n const src = thumbnail(attachment.mimeType, attachment.data);\n return <Chip key={attachment.id} data-testid=\"attachment\" className=\"max-w-full md:max-w-[14rem]\" image={!!src}\n media={src ? <img src={src} alt=\"\" /> : <FileTextIcon aria-hidden=\"true\" />} label={attachment.name}\n actions={<RemoveChip label={labels.remove(attachment.name)} onClick={() => props.onRemoveAttachment(attachment.id)} />} />;\n })}\n </div>}\n </>;\n const pill = layout === 'pill';\n const textareaEl = <InputGroupTextarea ref={setRef} data-testid=\"composer-input\" aria-label={labels.messageInput} rows={1} value={text} disabled={disabled}\n placeholder={props.placeholderContent ? '' : props.placeholder ?? (working ? labels.placeholderWorking : labels.placeholder)}\n {...(slash || mentions ? { role: 'combobox', 'aria-expanded': menuOpen, 'aria-haspopup': 'listbox', 'aria-autocomplete': 'list' } as const : {})}\n onChange={onChange} onKeyDown={onKeyDown} onPaste={onPaste} onKeyUp={event => trackCaret(event.currentTarget)} onClick={event => trackCaret(event.currentTarget)}\n onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n className={cn(props.markMentionQuery && 'relative', pill ? 'max-h-52 min-h-0 min-w-0' : 'max-h-52 min-h-11')} />;\n // The query mark is a backdrop with the textarea's own metrics behind its transparent text: the text the person edits stays the textarea's.\n const marked = props.markMentionQuery && mentionOpen && trigger ? <QueryBackdrop text={text} start={trigger.start} end={trigger.end} /> : null;\n // eslint-disable-next-line react-hooks/refs -- renderInput receives event callbacks, not ref values; an input calls them only on interaction\n const input = props.renderInput?.({ text, onText, onCaret: setCaret, onSend: () => { if (canSubmit && !menuOpen) props.onSend(); } }) ?? props.input ?? (props.placeholderContent || marked ? <div data-slot=\"composer-field\" className=\"relative flex min-h-16 w-full min-w-0 flex-1\">{marked}{textareaEl}{!text && props.placeholderContent && <span aria-hidden=\"true\" data-slot=\"composer-placeholder\" className=\"pointer-events-none absolute inset-x-3 top-2 text-sm text-muted-foreground\">{props.placeholderContent}</span>}</div> : textareaEl);\n const plus = (canAttach || items.length > 0) && <PlusActions disabled={disabled} textarea={inner} onAttach={canAttach ? () => fileInput.current?.click() : undefined} onTakePhoto={canPhoto ? () => setCameraOpen(true) : undefined} onCommands={items.length > 0 ? openCommands : undefined} />;\n // One child slot either way, so a composer without `start` keeps its exact markup (React ids included).\n const startAndPlus = props.start ? <><div data-testid=\"composer-start\" className=\"flex shrink-0 items-center gap-1\">{props.start}</div>{plus}</> : plus;\n const fileEl = canAttach && <Input ref={fileInput} type=\"file\" multiple accept={props.fileAccept} disabled={disabled} tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"composer-file\" data-attach=\"true\"\n onChange={event => { const files = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ''; props.onPickFiles(files); }} />;\n const send = working\n ? <Button variant=\"default\" size=\"icon\" data-testid=\"composer-submit\" data-state=\"stop\" aria-label={labels.stop} title={labels.stop} disabled={(disabled && !props.stopOpen) || stopRequested} onClick={props.onStop} className=\"shrink-0\">\n {stopRequested ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" /> : <icons.stop data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>\n : props.whenEmpty && !hasContent ? <div data-testid=\"composer-when-empty\" className=\"flex shrink-0 items-center\">{props.whenEmpty}</div>\n : <Button variant=\"default\" size=\"icon\" type=\"submit\" data-testid=\"composer-submit\" data-state=\"send\" aria-label={labels.send} title={labels.sendHint} disabled={!canSubmit} className=\"shrink-0\">\n <icons.send data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n const end = <>\n {props.barStart}\n {props.chips && <div className=\"flex min-w-0 items-center gap-2\">{props.chips}</div>}\n {props.barNote}\n {uploading && !(props.uploads?.length) && <span role=\"status\" className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"><Loader2Icon className=\"size-3 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.preparingAttachments}</span>}\n {!pill && <span className=\"min-w-0 flex-1\" />}\n {props.barEnd && <div data-testid=\"composer-tools\" className=\"flex min-w-0 items-center gap-2\">{props.barEnd}</div>}\n {working && hasContent && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-queue\" aria-label={labels.queue} title={labels.queue} disabled={!canSubmit} onClick={props.onSend} className=\"shrink-0\">\n <icons.queue data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {send}\n </>;\n\n // One shadcn InputGroup: the chips, the text and a row of controls (`card`, also used for the `stacked` and `inline` compatibility\n // values), or one row with the text between the controls (`pill`, chips above it). Its look is the host's; ours is layout only.\n return <form data-testid=\"composer\" data-layout={layout} data-dragging={dragging || undefined} onSubmit={event => { event.preventDefault(); if (canSubmit) props.onSend(); }} onDragOver={drag} onDragLeave={drag} onDrop={drop}\n className={cn('relative flex flex-col gap-2', pill && '@container/composer')}>\n {fileEl}\n {slashOpen && Slash && <Slash query={query!} items={items} onSelect={pickSlash} onDismiss={dismiss} />}\n {mentionOpen && mentions && Mention && <Mention query={trigger!.query} listLabel={mentions.listLabel} renderOption={mentions.renderOption} header={typeof mentions.pickerHeader === 'function' ? mentions.pickerHeader(trigger!.query) : mentions.pickerHeader} search={mentions.search} generation={mentions.generation} onSelect={pickMention} onDismiss={dismiss} />}\n {pill && !props.recording && chipList && <div className=\"flex flex-wrap gap-2\">{chipList}</div>}\n {props.recording ? <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\" className=\"w-full\">{props.recording}</InputGroupAddon>\n </InputGroup> : pill ? <InputGroup>\n <InputGroupAddon>{startAndPlus}{feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}</InputGroupAddon>\n {input}\n <InputGroupAddon align=\"inline-end\" data-testid=\"composer-controls\">{end}</InputGroupAddon>\n </InputGroup> : <InputGroup>\n {chipList && <InputGroupAddon align=\"block-start\" className=\"flex-wrap\">{chipList}</InputGroupAddon>}\n {input}\n <InputGroupAddon align=\"block-end\" data-testid=\"composer-controls\">\n {startAndPlus}\n {feedback && <AnnotateButton feedback={feedback} disabled={disabled} />}\n {end}\n </InputGroupAddon>\n </InputGroup>}\n {cameraOpen && Camera && <Camera onClose={() => setCameraOpen(false)} onCapture={file => { setCameraOpen(false); props.onPickFiles([file]); }} />}\n </form>;\n}\n",
40
40
  "type": "registry:component",
41
41
  "target": "components/pi-chat/composer.tsx"
42
42
  },
@@ -48,7 +48,7 @@
48
48
  },
49
49
  {
50
50
  "path": "registry/pi-chat/config.ts",
51
- "content": "import type { ComponentType, ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageConfig as KitVoiceMessageConfig, PendingVoice, VoiceMessageHandle, VoiceRecorderOptions as KitVoiceRecorderOptions } from '@hachej/boring-ui-kit/chat-react/voice';\nexport type { PendingVoice, VoiceMessageHandle, VoicePhase } from '@hachej/boring-ui-kit/chat-react/voice';\nimport type { ApprovalDecision, ArtifactDetect, ArtifactRecognise, ChatArtifact, SubagentRenderer as ViewSubagentRenderer } from '@hachej/boring-ui-kit/chat-view';\nimport type { BlockAction } from '../button/actions';\nimport type { ChatLabels } from './labels';\n\n/** A host's look for one sent text: its card and the words around it, or nothing to keep the plain bubble. */\nexport type SentContent = (text: string, align: 'start' | 'end') => { readonly before: string; readonly node: ReactNode; readonly after: string } | undefined;\n\n/*\n * Composer features the host switches on by configuration. Each prop is optional: leave it out and the composer has\n * no menu, no button and no keyboard trigger for it. The current model and thinking level are read from the native\n * view's `pi.agent` document, so there is no extra state to keep in sync. The component that draws a feature comes from its own\n * registry item and rides on the config (`Picker`, `Camera`, `useRecorder` with `Button` and `Bubble`): without it the feature is\n * simply not offered (no menu, no pill, no camera entry, no record button). `withChatPlugins` of `pi-chat-plugins` fills them all in.\n */\n\n/** Where a `/` menu entry comes from (the kit's `chat-text`). */\nexport type { SlashSource } from '@hachej/boring-ui-kit/chat-text';\n\nexport interface SlashApi {\n /** Replace the composer text. */\n readonly setText: (text: string) => void;\n /** The composer text with the typed `/query` already removed. */\n readonly text: string;\n}\nexport interface SlashCommand {\n readonly name: string;\n readonly description?: string;\n /** Shown as a badge and as a filter chip; commands without one are grouped as `built-in`. */\n readonly sourcePlugin?: string;\n readonly run: (api: SlashApi) => void | Promise<void>;\n}\nexport interface SlashSkill {\n readonly name: string;\n readonly description: string;\n readonly sourcePlugin?: string;\n}\nexport interface SlashConfig {\n readonly commands?: readonly SlashCommand[];\n readonly skills?: readonly SlashSkill[];\n /** The `/` menu (`SlashPicker` of `pi-chat-slash`). Without it there is no `/` menu and no Commands entry. */\n readonly Picker?: ComponentType<SlashPickerProps> | undefined;\n}\n\nexport interface MentionResult { readonly path: string; readonly kind?: 'file' | 'directory'; readonly label?: string }\nexport interface MentionKind {\n readonly label: (path: string) => string;\n readonly icon: ReactNode;\n readonly open?: ((path: string) => void) | undefined;\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n}\nexport interface MentionsConfig {\n /** App mentions use `@prefix:path` (or the existing quoted token for spaces). Prefixes match [a-z][a-z0-9-]*. No picker is required. */\n readonly kinds?: Readonly<Record<string, MentionKind>> | undefined;\n /** Accessible name and optional option/media presentation for a host catalog. */\n readonly listLabel?: string;\n readonly icon?: ReactNode;\n readonly renderOption?: (item: MentionResult, query: string) => ReactNode;\n /** Above the picker's rows: a node, or one built from the typed query (« TÂCHES · d-2 »). */\n readonly pickerHeader?: ReactNode | ((query: string) => ReactNode);\n readonly label?: (id: string) => string;\n /** Workspace search for the `@` picker. Empty `query` asks for a default listing. Abort `signal` when the query changes. */\n readonly search: (query: string, signal: AbortSignal) => Promise<readonly MentionResult[]>;\n /** Open a mentioned workspace file (for example in the right panel). When given, a mention in a sent message is a button. */\n readonly open?: ((path: string) => void) | undefined;\n /** Changes whenever what `search` would return may have changed (a workspace change event, another workspace). An open picker searches again. */\n readonly generation?: string | number | undefined;\n /** Host-supplied look for a mention in a sent message (a plug-in such as feedback). Return undefined to keep the default mention. */\n readonly render?: ((path: string) => ReactNode | undefined) | undefined;\n /** Sent-message rendering, independent of the composer's token presentation. */\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n /**\n * Upload bytes through the host's provider. Image cards read thumbnails; voice bubbles read audio only on Play.\n * Thumbnails accept png, jpeg, gif and webp. Audio must retain its recorded audio MIME type.\n */\n readonly preview?: PreviewRead | undefined;\n /** Optional authorized playback URL, requested only on Play; otherwise voice audio uses `preview`. */\n readonly audioUrl?: AudioUrlRead | undefined;\n /** The `@` picker (`MentionPicker` of `pi-chat-mentions`). Without it typing `@` opens nothing. */\n readonly Picker?: ComponentType<MentionPickerProps> | undefined;\n}\nexport type AudioUrlRead = (path: string, signal: AbortSignal) => Promise<string | undefined>;\n/** Reads an upload for its thumbnail or lazy voice playback: its media type and base64 data, or undefined. */\nexport type PreviewRead = (path: string, signal: AbortSignal) => Promise<{ readonly mimeType: string; readonly data: string } | undefined>;\n\n/** Paste-to-file thresholds (the kit's `chat-text`): a paste with more characters or more lines than these becomes a file. */\nexport type { PasteToFile } from '@hachej/boring-ui-kit/chat-text';\n/** A paste the composer took, with the textarea range it replaces (restored if the upload is cancelled). */\n// Compatibility exports for one release; session contracts are owned by the kit.\nexport type { PastedText, UploadResult, ModelRef } from '@hachej/boring-ui-kit/chat-react';\nimport type { UploadResult, ModelRef } from '@hachej/boring-ui-kit/chat-react';\nexport interface VoiceMessageConfig extends KitVoiceMessageConfig {\n /** Same workspace upload contract as `attachments.upload`, used instead when supplied. Preserve the voice- filename prefix. */\n readonly upload?: AttachmentsConfig['upload'];\n /** The recorder (`useVoiceMessage` of `pi-chat-voice`). Without it there is no voice message: no button, no pending bubble. */\n readonly useRecorder?: VoiceRecorderHook | undefined;\n /** The hold-to-record button at the end of the composer bar (`VoiceMessageButton` of `pi-chat-voice`). */\n readonly Button?: ComponentType<VoiceMessageButtonProps> | undefined;\n /** A voice message on its way: recording, transcribing, uploading (`PendingVoiceBubble` of `pi-chat-voice`). */\n readonly Bubble?: ComponentType<PendingVoiceBubbleProps> | undefined;\n}\n\nexport interface AttachmentsConfig {\n /** The file input's `accept`. Any file when omitted. */\n readonly accept?: string;\n readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;\n /** The full-screen camera the attach menu opens on a phone (`CameraSheet` of `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly Camera?: ComponentType<CameraSheetProps> | undefined;\n}\n\n// Compatibility type export for one release.\nexport type { ConversationItem } from '@hachej/boring-ui-kit/chat-react';\nimport type { ConversationItem } from '@hachej/boring-ui-kit/chat-react';\n/** The host's conversations. Give it to `PiChat` and the header's History button opens a list of them instead of the earlier records of this one. */\nexport interface ConversationsConfig {\n readonly items: readonly ConversationItem[];\n /** The conversation that is open; it is marked in the list. */\n readonly activeId?: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onNew?: (() => void) | undefined;\n readonly loading?: boolean | undefined;\n /**\n * Server-side search: with it, a typed query (or the Archived filter) lists what this returns instead of filtering `items`\n * by title. Abort `signal` when the query changes.\n */\n readonly search?: ((query: string, options: { readonly archived: boolean }, signal: AbortSignal) => Promise<readonly ConversationItem[]>) | undefined;\n /** Each action below adds its button to the rows; the host refreshes `items` when it resolves. */\n readonly rename?: ((id: string, title: string) => Promise<void>) | undefined;\n /** Archive (`true`) or restore (`false`). With it, the list also offers an Archived filter (which needs `search`). */\n readonly archive?: ((id: string, archived: boolean) => Promise<void>) | undefined;\n readonly remove?: ((id: string) => Promise<void>) | undefined;\n /** Fork the open conversation after the entry of a settled reply: the reply's Fork button. */\n readonly fork?: ((atEntryId: string) => Promise<void>) | undefined;\n /**\n * Host actions on each row, after rename, archive and delete: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `conversation-action-<id>`, the menu `conversation-action-more`.\n */\n readonly rowActions?: ((item: ConversationItem) => readonly BlockAction[]) | undefined;\n}\n\n/** A settled reply, as host message actions receive it: its row key, its text and the native entry it ends with. */\nexport interface ReplyRef {\n readonly key: string; readonly text: string; readonly entryId?: string | undefined;\n /** When the last model round of the reply was written (ms), the same value as its message's `timestamp`: a host keys its own per-reply data on it. */\n readonly timestamp?: number | undefined;\n /** The model that wrote the last round of the reply, as its message names it (`provider` and `model` ids). */\n readonly model?: { readonly provider: string; readonly modelId: string } | undefined;\n /**\n * Sum of the model rounds that form the reply (ms): Pi's own `AssistantMessage.durationMs`, measured monotonically\n * and kept in the transcript. It excludes queue time, tool runs and retry delays, and is absent for a message written\n * before Pi recorded it. It is never computed from the clock at render time.\n */\n readonly durationMs?: number | undefined;\n}\n\n/** The host's per-turn record of the model that answered a reply; `fallback` is why it is not the model the agent asked for. */\nexport interface AnsweredBy { readonly provider: string; readonly model: string; readonly fallback?: string | undefined }\n\nexport interface ModelConfig {\n readonly options: readonly (ModelRef & { readonly label?: string })[];\n /** Reject to refuse: the message is shown inline. */\n readonly change: (model: ModelRef) => Promise<void>;\n /** The one model-and-effort pill (`ModelEffortPicker` of `pi-chat-pickers`; `effort.Picker` is used when only it is set). Without one there is no pill. */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\nexport interface EffortConfig {\n readonly options: readonly string[];\n readonly change: (level: string) => Promise<void>;\n /** The one model-and-effort pill, when `model` does not carry one (`ModelEffortPicker` of `pi-chat-pickers`). */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\n\n/*\n * The text grammar (slash menu, `@mention` tokens, message pieces, file kinds, paste and draft rules) is the kit's `chat-text`; it is\n * re-exported here so the items that import it from `pi-chat/config` keep compiling.\n */\nexport { appendToDraft, displayName, fileKind, hasMention, isLongPaste, isUploadPath, isVoicePath, looksLikeMarkdown, MENTION_TOKEN, mentionedPaths, mentionKind, mentionToken, mentionTrigger, pieces, prependToDraft, removeMention, slashGroup, slashItems, slashQuery, visibleParts, withoutKindMentions, withoutMentions } from '@hachej/boring-ui-kit/chat-text';\nexport type { FileKind, MentionTrigger, Piece, SlashItem } from '@hachej/boring-ui-kit/chat-text';\n\n/** Props of the slash picker that `SlashConfig.Picker` carries. */\nexport interface SlashPickerProps {\n readonly query: string;\n readonly items: readonly SlashItem[];\n readonly onSelect: (item: SlashItem) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the mention picker that `MentionsConfig.Picker` carries. */\nexport interface MentionPickerProps {\n readonly query: string;\n readonly listLabel?: MentionsConfig['listLabel'];\n readonly renderOption?: MentionsConfig['renderOption'];\n readonly header?: ReactNode;\n readonly generation?: MentionsConfig['generation'];\n readonly search: MentionsConfig['search'];\n readonly onSelect: (path: string) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the model and effort picker that `ModelConfig.Picker` or `EffortConfig.Picker` carries. */\nexport interface ModelEffortPickerProps {\n readonly model: ModelConfig | undefined; readonly effort: EffortConfig | undefined;\n readonly currentModel: ModelRef | undefined; readonly currentEffort: string | undefined;\n readonly disabled: boolean; readonly busy: 'model' | 'effort' | null;\n readonly onModel: (model: ModelRef) => void; readonly onEffort: (level: string) => void;\n}\n\n/** Props of the tap-to-record button that `VoiceMessageConfig.Button` carries. */\n/** `primary`: the button stands in for Send (`voiceReplacesSend`), so it takes Send's primary look. */\nexport interface VoiceMessageButtonProps {\n readonly voice: VoiceMessageHandle;\n readonly disabled?: boolean;\n readonly primary?: boolean;\n readonly testId?: string;\n}\n/** Props of a pending voice message bubble that `VoiceMessageConfig.Bubble` carries. */\nexport interface PendingVoiceBubbleProps { readonly pending: PendingVoice | undefined }\n/** What the chat hands its recorder (`VoiceMessageConfig.useRecorder`). `config` is undefined when voice is off. */\nexport interface VoiceRecorderOptions extends KitVoiceRecorderOptions {\n readonly config: VoiceMessageConfig | undefined;\n readonly attachments?: AttachmentsConfig | undefined;\n readonly labels: ChatLabels;\n}\n/** A React hook: the chat calls it on every render of a mounted chat (a new hook remounts the chat). */\nexport type VoiceRecorderHook = (options: VoiceRecorderOptions) => VoiceMessageHandle;\n\n/** Props of the phone camera sheet that `AttachmentsConfig.Camera` carries. */\nexport interface CameraSheetProps {\n /** The photo, as a JPEG File scaled to at most `CAMERA_MAX_SIDE` on its longest side (or the phone camera's own file, from the fallback). */\n readonly onCapture: (file: File) => void;\n readonly onClose: () => void;\n /** Opens the camera; defaults to the browser's, when it has one. */\n readonly getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;\n}\n\n/** A `/command` a settled reply may turn into a button (`commandMentions`; drawn by a text renderer such as `pi-chat-markdown`). */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\n/** A recording's length as m:ss. */\nexport const voiceClock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n/*\n * The cards of a reply are items too (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`, `pi-artifacts`). The core keeps only what\n * it needs to place them: the props each card receives, and a visible \"waiting for your answer\" notice when a question or an approval has\n * no card, so a run never waits silently. `withChatPlugins` of `pi-chat-plugins` plugs every card in.\n */\n\n/** What the host answers when asked to resolve a pending question. */\nexport type AnswerOutcome = { readonly kind: 'answered' } | { readonly kind: 'denied' | 'conflict' | 'unknown-question'; readonly reason?: string };\n/** `ask_user` calls are question rows; `ApprovalDecision` is what `requireApproval` records (both owned by the kit's chat view). */\nexport { ASK_USER_TOOL } from '@hachej/boring-ui-kit/chat-view';\nexport type { ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\n\n/** Props of the question card that `ChatCards.question` carries (`QuestionCard` of `pi-chat-question`). */\nexport interface QuestionCardProps {\n readonly call: ToolCall;\n readonly result: ToolResultMessage | undefined;\n readonly questionId: string | undefined;\n readonly answer?: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be answered yet. */\n readonly live?: boolean;\n}\n/** Props of the approval card that `ChatCards.approval` carries (`ApprovalCard` of `pi-chat-approval`). Answering uses the host's `answer`. */\nexport interface ApprovalCardProps {\n readonly call: ToolCall;\n /** The question ID bound to this call's assistant entry (`derive` provides it); answers use it, never the bare call ID. */\n readonly questionId?: string | undefined;\n /** The decision `requireApproval` recorded in the call's details (`derive` finds it). Without one, a settled call shows as not decided. */\n readonly decision?: ApprovalDecision | undefined;\n /** The one-line summary `requireApproval` published for this call (`derive` finds it). Shown first; the raw arguments are then collapsed under it. */\n readonly summary?: string | undefined;\n readonly result: ToolResultMessage | undefined;\n readonly answer?: ((callId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be decided yet. */\n readonly live?: boolean;\n}\n/**\n * Draws a tool call that ran a sub-agent (`subagentCard` of `pi-chat-subagent`), from the details its running slot published (`details`)\n * or that it finished with. Undefined: the call is not a sub-agent call and stays an ordinary tool step.\n */\nexport type SubagentRenderer = ViewSubagentRenderer<ReactNode>;\n\n/** The cards the chat draws for a human decision or a sub-agent. Each one left out has no card (see `PiChat`'s `cards`). */\nexport interface ChatCards {\n /** An `ask_user` question (`QuestionCard` of `pi-chat-question`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly question?: ComponentType<QuestionCardProps> | undefined;\n /** A call gated by `requireApproval` (`ApprovalCard` of `pi-chat-approval`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly approval?: ComponentType<ApprovalCardProps> | undefined;\n /** A sub-agent call (`subagentCard` of `pi-chat-subagent`). Without it the call is an ordinary tool step. */\n readonly subagent?: SubagentRenderer | undefined;\n}\n\n/** An artifact a tool call made, as the chat places it (owned by the kit's chat view). */\nexport type { ChatArtifact };\n/** Props of the artifact card that `ArtifactsConfig.Card` carries (`PluggedArtifactCard` of `pi-artifacts`). */\nexport interface ChatArtifactCardProps {\n readonly artifact: ChatArtifact;\n readonly open: boolean;\n readonly onOpen: (() => void) | undefined;\n readonly kinds: readonly unknown[] | undefined;\n}\n/**\n * Artifact cards, as the core sees `ArtifactsConfig` of `pi-artifacts`: the host's opener and the item's `recognise` and `Card`. Without\n * `recognise` and `Card` no artifact is offered: the calls stay ordinary tool steps.\n */\nexport interface ChatArtifactsConfig {\n readonly kinds?: readonly unknown[] | undefined;\n readonly open: (artifact: never) => void;\n readonly isOpen?: ((artifact: never) => boolean) | undefined;\n readonly detect?: ArtifactDetect | undefined;\n /** The artifact a call made, or that a running call (`running`) is making; undefined for any other call. */\n readonly recognise?: ArtifactRecognise | undefined;\n readonly Card?: ComponentType<ChatArtifactCardProps> | undefined;\n}\n",
51
+ "content": "import type { ComponentType, ReactNode } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { SlashItem } from '@hachej/boring-ui-kit/chat-text';\nimport type { VoiceMessageConfig as KitVoiceMessageConfig, PendingVoice, VoiceMessageHandle, VoiceRecorderOptions as KitVoiceRecorderOptions } from '@hachej/boring-ui-kit/chat-react/voice';\nexport type { PendingVoice, VoiceMessageHandle, VoicePhase } from '@hachej/boring-ui-kit/chat-react/voice';\nimport type { ApprovalDecision, ArtifactDetect, ArtifactRecognise, ChatArtifact, SubagentRenderer as ViewSubagentRenderer } from '@hachej/boring-ui-kit/chat-view';\nimport type { BlockAction } from '../button/actions';\nimport type { ChatLabels } from './labels';\n\n/** A host's look for one sent text: its card and the words around it, or nothing to keep the plain bubble. */\nexport type SentContent = (text: string, align: 'start' | 'end') => { readonly before: string; readonly node: ReactNode; readonly after: string } | undefined;\n\n/*\n * Composer features the host switches on by configuration. Each prop is optional: leave it out and the composer has\n * no menu, no button and no keyboard trigger for it. The current model and thinking level are read from the native\n * view's `pi.agent` document, so there is no extra state to keep in sync. The component that draws a feature comes from its own\n * registry item and rides on the config (`Picker`, `Camera`, `useRecorder` with `Button` and `Bubble`): without it the feature is\n * simply not offered (no menu, no pill, no camera entry, no record button). `withChatPlugins` of `pi-chat-plugins` fills them all in.\n */\n\n/** Where a `/` menu entry comes from (the kit's `chat-text`). */\nexport type { SlashSource } from '@hachej/boring-ui-kit/chat-text';\n\nexport interface SlashApi {\n /** Replace the composer text. */\n readonly setText: (text: string) => void;\n /** The composer text with the typed `/query` already removed. */\n readonly text: string;\n}\nexport interface SlashCommand {\n readonly name: string;\n readonly description?: string;\n /** Shown as a badge and as a filter chip; commands without one are grouped as `built-in`. */\n readonly sourcePlugin?: string;\n readonly run: (api: SlashApi) => void | Promise<void>;\n}\nexport interface SlashSkill {\n readonly name: string;\n readonly description: string;\n readonly sourcePlugin?: string;\n}\nexport interface SlashConfig {\n readonly commands?: readonly SlashCommand[];\n readonly skills?: readonly SlashSkill[];\n /** The `/` menu (`SlashPicker` of `pi-chat-slash`). Without it there is no `/` menu and no Commands entry. */\n readonly Picker?: ComponentType<SlashPickerProps> | undefined;\n}\n\nexport interface MentionResult { readonly path: string; readonly kind?: 'file' | 'directory'; readonly label?: string }\nexport interface MentionKind {\n readonly label: (path: string) => string;\n readonly icon: ReactNode;\n readonly open?: ((path: string) => void) | undefined;\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n}\nexport interface MentionsConfig {\n /** App mentions use `@prefix:path` (or the existing quoted token for spaces). Prefixes match [a-z][a-z0-9-]*. No picker is required. */\n readonly kinds?: Readonly<Record<string, MentionKind>> | undefined;\n /** Accessible name and optional option/media presentation for a host catalog. */\n readonly listLabel?: string;\n readonly icon?: ReactNode;\n readonly renderOption?: (item: MentionResult, query: string) => ReactNode;\n /** Above the picker's rows: a node, or one built from the typed query (« TÂCHES · d-2 »). */\n readonly pickerHeader?: ReactNode | ((query: string) => ReactNode);\n readonly label?: (id: string) => string;\n /** Workspace search for the `@` picker. Empty `query` asks for a default listing. Abort `signal` when the query changes. */\n readonly search: (query: string, signal: AbortSignal) => Promise<readonly MentionResult[]>;\n /** Open a mentioned workspace file (for example in the right panel). When given, a mention in a sent message is a button. */\n readonly open?: ((path: string) => void) | undefined;\n /** Changes whenever what `search` would return may have changed (a workspace change event, another workspace). An open picker searches again. */\n readonly generation?: string | number | undefined;\n /** Host-supplied look for a mention in a sent message (a plug-in such as feedback). Return undefined to keep the default mention. */\n readonly render?: ((path: string) => ReactNode | undefined) | undefined;\n /** Sent-message rendering, independent of the composer's token presentation. */\n readonly renderSent?: ((path: string) => ReactNode | undefined) | undefined;\n /**\n * Upload bytes through the host's provider. Image cards read thumbnails; voice bubbles read audio only on Play.\n * Thumbnails accept png, jpeg, gif and webp. Audio must retain its recorded audio MIME type.\n */\n readonly preview?: PreviewRead | undefined;\n /** Optional authorized playback URL, requested only on Play; otherwise voice audio uses `preview`. */\n readonly audioUrl?: AudioUrlRead | undefined;\n /** The `@` picker (`MentionPicker` of `pi-chat-mentions`). Without it typing `@` opens nothing. */\n readonly Picker?: ComponentType<MentionPickerProps> | undefined;\n}\nexport type AudioUrlRead = (path: string, signal: AbortSignal) => Promise<string | undefined>;\n/** Reads an upload for its thumbnail or lazy voice playback: its media type and base64 data, or undefined. */\nexport type PreviewRead = (path: string, signal: AbortSignal) => Promise<{ readonly mimeType: string; readonly data: string } | undefined>;\n\n/** Paste-to-file thresholds (the kit's `chat-text`): a paste with more characters or more lines than these becomes a file. */\nexport type { PasteToFile } from '@hachej/boring-ui-kit/chat-text';\n/** A paste the composer took, with the textarea range it replaces (restored if the upload is cancelled). */\n// Compatibility exports for one release; session contracts are owned by the kit.\nexport type { PastedText, UploadResult, ModelRef } from '@hachej/boring-ui-kit/chat-react';\nimport type { UploadResult, ModelRef } from '@hachej/boring-ui-kit/chat-react';\nexport interface VoiceMessageConfig extends KitVoiceMessageConfig {\n /** Same workspace upload contract as `attachments.upload`, used instead when supplied. Preserve the voice- filename prefix. */\n readonly upload?: AttachmentsConfig['upload'];\n /** The recorder (`useVoiceMessage` of `pi-chat-voice`). Without it there is no voice message: no button, no pending bubble. */\n readonly useRecorder?: VoiceRecorderHook | undefined;\n /** The hold-to-record button at the end of the composer bar (`VoiceMessageButton` of `pi-chat-voice`). */\n readonly Button?: ComponentType<VoiceMessageButtonProps> | undefined;\n /** A voice message on its way: recording, transcribing, uploading (`PendingVoiceBubble` of `pi-chat-voice`). */\n readonly Bubble?: ComponentType<PendingVoiceBubbleProps> | undefined;\n}\n\nexport interface AttachmentsConfig {\n /** The file input's `accept`. Any file when omitted. */\n readonly accept?: string;\n readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;\n /** The full-screen camera the attach menu opens on a phone (`CameraSheet` of `pi-chat-attachments`). Without it there is no Take a photo entry. */\n readonly Camera?: ComponentType<CameraSheetProps> | undefined;\n}\n\n// Compatibility type export for one release.\nexport type { ConversationItem } from '@hachej/boring-ui-kit/chat-react';\nimport type { ConversationItem } from '@hachej/boring-ui-kit/chat-react';\n/** The host's conversations. Give it to `PiChat` and the header's History button opens a list of them instead of the earlier records of this one. */\nexport interface ConversationsConfig {\n readonly items: readonly ConversationItem[];\n /** The conversation that is open; it is marked in the list. */\n readonly activeId?: string | undefined;\n readonly onSelect: (id: string) => void;\n readonly onNew?: (() => void) | undefined;\n /** New or fork is on its way: the composer is disabled, so no message goes to the conversation being left. */\n readonly creating?: boolean | undefined;\n /** The conversation New or fork just opened: once it is `activeId`, the cursor goes to its message box, on a computer. */\n readonly opened?: string | undefined;\n readonly loading?: boolean | undefined;\n /**\n * Server-side search: with it, a typed query (or the Archived filter) lists what this returns instead of filtering `items`\n * by title. Abort `signal` when the query changes.\n */\n readonly search?: ((query: string, options: { readonly archived: boolean }, signal: AbortSignal) => Promise<readonly ConversationItem[]>) | undefined;\n /** Each action below adds its button to the rows; the host refreshes `items` when it resolves. */\n readonly rename?: ((id: string, title: string) => Promise<void>) | undefined;\n /** Archive (`true`) or restore (`false`). With it, the list also offers an Archived filter (which needs `search`). */\n readonly archive?: ((id: string, archived: boolean) => Promise<void>) | undefined;\n readonly remove?: ((id: string) => Promise<void>) | undefined;\n /** Fork the open conversation after the entry of a settled reply: the reply's Fork button. */\n readonly fork?: ((atEntryId: string) => Promise<void>) | undefined;\n /**\n * Host actions on each row, after rename, archive and delete: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `conversation-action-<id>`, the menu `conversation-action-more`.\n */\n readonly rowActions?: ((item: ConversationItem) => readonly BlockAction[]) | undefined;\n}\n\n/** A settled reply, as host message actions receive it: its row key, its text and the native entry it ends with. */\nexport interface ReplyRef {\n readonly key: string; readonly text: string; readonly entryId?: string | undefined;\n /** When the last model round of the reply was written (ms), the same value as its message's `timestamp`: a host keys its own per-reply data on it. */\n readonly timestamp?: number | undefined;\n /** The model that wrote the last round of the reply, as its message names it (`provider` and `model` ids). */\n readonly model?: { readonly provider: string; readonly modelId: string } | undefined;\n /**\n * Sum of the model rounds that form the reply (ms): Pi's own `AssistantMessage.durationMs`, measured monotonically\n * and kept in the transcript. It excludes queue time, tool runs and retry delays, and is absent for a message written\n * before Pi recorded it. It is never computed from the clock at render time.\n */\n readonly durationMs?: number | undefined;\n}\n\n/** The host's per-turn record of the model that answered a reply; `fallback` is why it is not the model the agent asked for. */\nexport interface AnsweredBy { readonly provider: string; readonly model: string; readonly fallback?: string | undefined }\n\nexport interface ModelConfig {\n readonly options: readonly (ModelRef & { readonly label?: string })[];\n /** Reject to refuse: the message is shown inline. */\n readonly change: (model: ModelRef) => Promise<void>;\n /** The one model-and-effort pill (`ModelEffortPicker` of `pi-chat-pickers`; `effort.Picker` is used when only it is set). Without one there is no pill. */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\nexport interface EffortConfig {\n readonly options: readonly string[];\n readonly change: (level: string) => Promise<void>;\n /** The one model-and-effort pill, when `model` does not carry one (`ModelEffortPicker` of `pi-chat-pickers`). */\n readonly Picker?: ComponentType<ModelEffortPickerProps> | undefined;\n}\n\n/*\n * The text grammar (slash menu, `@mention` tokens, message pieces, file kinds, paste and draft rules) is the kit's `chat-text`; it is\n * re-exported here so the items that import it from `pi-chat/config` keep compiling.\n */\nexport { appendToDraft, displayName, fileKind, hasMention, isLongPaste, isUploadPath, isVoicePath, looksLikeMarkdown, MENTION_TOKEN, mentionedPaths, mentionKind, mentionToken, mentionTrigger, pieces, prependToDraft, removeMention, slashGroup, slashItems, slashQuery, visibleParts, withoutKindMentions, withoutMentions } from '@hachej/boring-ui-kit/chat-text';\nexport type { FileKind, MentionTrigger, Piece, SlashItem } from '@hachej/boring-ui-kit/chat-text';\n\n/** Props of the slash picker that `SlashConfig.Picker` carries. */\nexport interface SlashPickerProps {\n readonly query: string;\n readonly items: readonly SlashItem[];\n readonly onSelect: (item: SlashItem) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the mention picker that `MentionsConfig.Picker` carries. */\nexport interface MentionPickerProps {\n readonly query: string;\n readonly listLabel?: MentionsConfig['listLabel'];\n readonly renderOption?: MentionsConfig['renderOption'];\n readonly header?: ReactNode;\n readonly generation?: MentionsConfig['generation'];\n readonly search: MentionsConfig['search'];\n readonly onSelect: (path: string) => void;\n readonly onDismiss: () => void;\n}\n\n/** Props of the model and effort picker that `ModelConfig.Picker` or `EffortConfig.Picker` carries. */\nexport interface ModelEffortPickerProps {\n readonly model: ModelConfig | undefined; readonly effort: EffortConfig | undefined;\n readonly currentModel: ModelRef | undefined; readonly currentEffort: string | undefined;\n readonly disabled: boolean; readonly busy: 'model' | 'effort' | null;\n readonly onModel: (model: ModelRef) => void; readonly onEffort: (level: string) => void;\n}\n\n/** Props of the tap-to-record button that `VoiceMessageConfig.Button` carries. */\n/** `primary`: the button stands in for Send (`voiceReplacesSend`), so it takes Send's primary look. */\nexport interface VoiceMessageButtonProps {\n readonly voice: VoiceMessageHandle;\n readonly disabled?: boolean;\n readonly primary?: boolean;\n readonly testId?: string;\n}\n/** Props of a pending voice message bubble that `VoiceMessageConfig.Bubble` carries. */\nexport interface PendingVoiceBubbleProps { readonly pending: PendingVoice | undefined }\n/** What the chat hands its recorder (`VoiceMessageConfig.useRecorder`). `config` is undefined when voice is off. */\nexport interface VoiceRecorderOptions extends KitVoiceRecorderOptions {\n readonly config: VoiceMessageConfig | undefined;\n readonly attachments?: AttachmentsConfig | undefined;\n readonly labels: ChatLabels;\n}\n/** A React hook: the chat calls it on every render of a mounted chat (a new hook remounts the chat). */\nexport type VoiceRecorderHook = (options: VoiceRecorderOptions) => VoiceMessageHandle;\n\n/** Props of the phone camera sheet that `AttachmentsConfig.Camera` carries. */\nexport interface CameraSheetProps {\n /** The photo, as a JPEG File scaled to at most `CAMERA_MAX_SIDE` on its longest side (or the phone camera's own file, from the fallback). */\n readonly onCapture: (file: File) => void;\n readonly onClose: () => void;\n /** Opens the camera; defaults to the browser's, when it has one. */\n readonly getUserMedia?: ((constraints: MediaStreamConstraints) => Promise<MediaStream>) | undefined;\n}\n\n/** A `/command` a settled reply may turn into a button (`commandMentions`; drawn by a text renderer such as `pi-chat-markdown`). */\nexport interface CommandMention { readonly name: string; readonly behavior: 'execute' | 'insert' }\nexport interface CommandMentions {\n readonly commands: readonly CommandMention[];\n readonly onActivate: (command: CommandMention) => void;\n}\n\n/** A recording's length as m:ss. */\nexport const voiceClock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;\n\n/*\n * The cards of a reply are items too (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`, `pi-artifacts`). The core keeps only what\n * it needs to place them: the props each card receives, and a visible \"waiting for your answer\" notice when a question or an approval has\n * no card, so a run never waits silently. `withChatPlugins` of `pi-chat-plugins` plugs every card in.\n */\n\n/** What the host answers when asked to resolve a pending question. */\nexport type AnswerOutcome = { readonly kind: 'answered' } | { readonly kind: 'denied' | 'conflict' | 'unknown-question'; readonly reason?: string };\n/** `ask_user` calls are question rows; `ApprovalDecision` is what `requireApproval` records (both owned by the kit's chat view). */\nexport { ASK_USER_TOOL } from '@hachej/boring-ui-kit/chat-view';\nexport type { ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\n\n/** Props of the question card that `ChatCards.question` carries (`QuestionCard` of `pi-chat-question`). */\nexport interface QuestionCardProps {\n readonly call: ToolCall;\n readonly result: ToolResultMessage | undefined;\n readonly questionId: string | undefined;\n readonly answer?: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be answered yet. */\n readonly live?: boolean;\n}\n/** Props of the approval card that `ChatCards.approval` carries (`ApprovalCard` of `pi-chat-approval`). Answering uses the host's `answer`. */\nexport interface ApprovalCardProps {\n readonly call: ToolCall;\n /** The question ID bound to this call's assistant entry (`derive` provides it); answers use it, never the bare call ID. */\n readonly questionId?: string | undefined;\n /** The decision `requireApproval` recorded in the call's details (`derive` finds it). Without one, a settled call shows as not decided. */\n readonly decision?: ApprovalDecision | undefined;\n /** The one-line summary `requireApproval` published for this call (`derive` finds it). Shown first; the raw arguments are then collapsed under it. */\n readonly summary?: string | undefined;\n readonly result: ToolResultMessage | undefined;\n readonly answer?: ((callId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n /** The call is still being generated by the model: it cannot be decided yet. */\n readonly live?: boolean;\n}\n/**\n * Draws a tool call that ran a sub-agent (`subagentCard` of `pi-chat-subagent`), from the details its running slot published (`details`)\n * or that it finished with. Undefined: the call is not a sub-agent call and stays an ordinary tool step.\n */\nexport type SubagentRenderer = ViewSubagentRenderer<ReactNode>;\n\n/** The cards the chat draws for a human decision or a sub-agent. Each one left out has no card (see `PiChat`'s `cards`). */\nexport interface ChatCards {\n /** An `ask_user` question (`QuestionCard` of `pi-chat-question`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly question?: ComponentType<QuestionCardProps> | undefined;\n /** A call gated by `requireApproval` (`ApprovalCard` of `pi-chat-approval`). Without it the chat says it waits for an answer, with no answer UI. */\n readonly approval?: ComponentType<ApprovalCardProps> | undefined;\n /** A sub-agent call (`subagentCard` of `pi-chat-subagent`). Without it the call is an ordinary tool step. */\n readonly subagent?: SubagentRenderer | undefined;\n}\n\n/** An artifact a tool call made, as the chat places it (owned by the kit's chat view). */\nexport type { ChatArtifact };\n/** Props of the artifact card that `ArtifactsConfig.Card` carries (`PluggedArtifactCard` of `pi-artifacts`). */\nexport interface ChatArtifactCardProps {\n readonly artifact: ChatArtifact;\n readonly open: boolean;\n readonly onOpen: (() => void) | undefined;\n readonly kinds: readonly unknown[] | undefined;\n}\n/**\n * Artifact cards, as the core sees `ArtifactsConfig` of `pi-artifacts`: the host's opener and the item's `recognise` and `Card`. Without\n * `recognise` and `Card` no artifact is offered: the calls stay ordinary tool steps.\n */\nexport interface ChatArtifactsConfig {\n readonly kinds?: readonly unknown[] | undefined;\n readonly open: (artifact: never) => void;\n readonly isOpen?: ((artifact: never) => boolean) | undefined;\n readonly detect?: ArtifactDetect | undefined;\n /** The artifact a call made, or that a running call (`running`) is making; undefined for any other call. */\n readonly recognise?: ArtifactRecognise | undefined;\n readonly Card?: ComponentType<ChatArtifactCardProps> | undefined;\n}\n",
52
52
  "type": "registry:lib",
53
53
  "target": "components/pi-chat/config.ts"
54
54
  },
@@ -84,7 +84,7 @@
84
84
  },
85
85
  {
86
86
  "path": "registry/pi-chat/pi-chat.tsx",
87
- "content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { viewHasEarlier } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { AnsweredBy, AttachmentsConfig, ConversationsConfig, PendingVoice, ReplyRef, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AnsweredBy, AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** `mode=\"developer\"`: the host's per-turn `answeredBy` for a reply, shown under it as « answered by provider/model » (and « fallback: reason »). Absent or undefined: the reply's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\nconst DEVELOPER_SWITCH = 'boring-ui:chat-mode';\n/** `?chatMode=developer` turns developer mode on in this browser until `?chatMode=expert`: the same transcript the browser already holds, drawn with its details. */\nfunction developerSwitch(): boolean {\n if (typeof window === 'undefined') return false;\n const asked = new URLSearchParams(window.location.search).get('chatMode');\n try {\n if (asked === 'developer') window.localStorage.setItem(DEVELOPER_SWITCH, 'developer');\n if (asked === 'expert') window.localStorage.removeItem(DEVELOPER_SWITCH);\n return asked !== 'expert' && (asked === 'developer' || window.localStorage.getItem(DEVELOPER_SWITCH) === 'developer');\n } catch { return asked === 'developer'; }\n}\nconst neverChanges = () => () => {};\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(hostProps: PiChatProps) {\n const developer = useSyncExternalStore(neverChanges, developerSwitch, () => false);\n const props = useMemo<PiChatProps>(() => {\n if (!developer) return hostProps;\n const { presentation: _natural, ...rest } = hostProps;\n return { ...rest, mode: 'developer' };\n }, [developer, hostProps]);\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, sentContent, messageStyle = 'default', answeredBy, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' || answeredBy ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}), ...(answeredBy ? { answeredBy } : {}) } : undefined, [canFork, messageStyle, answeredBy]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && (hidden > 0 || state.history.kind !== 'idle' || (viewHasEarlier(state.view) ?? state.view?.entries.some(entry => entry.head !== undefined))) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && !isAtBottom && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1\"><MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton></div>}\n </MessageScroller>}\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
87
+ "content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { viewHasEarlier } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { Loader2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { Composer } from './composer';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport { ConversationFiles } from './conversation-files';\nimport type { AnsweredBy, AttachmentsConfig, ConversationsConfig, PendingVoice, ReplyRef, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useCursorInOpened, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AnsweredBy, AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** `mode=\"developer\"`: the host's per-turn `answeredBy` for a reply, shown under it as « answered by provider/model » (and « fallback: reason »). Absent or undefined: the reply's own provider/model. */\n readonly answeredBy?: ((reply: ReplyRef) => AnsweredBy | undefined) | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\nconst DEVELOPER_SWITCH = 'boring-ui:chat-mode';\n/** `?chatMode=developer` turns developer mode on in this browser until `?chatMode=expert`: the same transcript the browser already holds, drawn with its details. */\nfunction developerSwitch(): boolean {\n if (typeof window === 'undefined') return false;\n const asked = new URLSearchParams(window.location.search).get('chatMode');\n try {\n if (asked === 'developer') window.localStorage.setItem(DEVELOPER_SWITCH, 'developer');\n if (asked === 'expert') window.localStorage.removeItem(DEVELOPER_SWITCH);\n return asked !== 'expert' && (asked === 'developer' || window.localStorage.getItem(DEVELOPER_SWITCH) === 'developer');\n } catch { return asked === 'developer'; }\n}\nconst neverChanges = () => () => {};\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(hostProps: PiChatProps) {\n const developer = useSyncExternalStore(neverChanges, developerSwitch, () => false);\n const props = useMemo<PiChatProps>(() => {\n if (!developer) return hostProps;\n const { presentation: _natural, ...rest } = hostProps;\n return { ...rest, mode: 'developer' };\n }, [developer, hostProps]);\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, sentContent, messageStyle = 'default', answeredBy, className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' || answeredBy ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}), ...(answeredBy ? { answeredBy } : {}) } : undefined, [canFork, messageStyle, answeredBy]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n // New or fork is on its way: nothing may go to this conversation (D-1499).\n const leaving = conversations?.creating === true;\n useCursorInOpened(textarea, conversations, composer.disabled || leaving);\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && (hidden > 0 || state.history.kind !== 'idle' || (viewHasEarlier(state.view) ?? state.view?.entries.some(entry => entry.head !== undefined))) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && !isAtBottom && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1\"><MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton></div>}\n </MessageScroller>}\n {(empty || loading) && <div className=\"pointer-events-none absolute inset-0\">\n <div className=\"pointer-events-auto h-full\">\n {loading ? <p role=\"status\" data-testid=\"loading\" className=\"flex h-full items-center justify-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>\n : emptyState ?? <EmptyState title={title} description={labels.emptyDescription} {...(avatar ? { avatar } : {})} {...(suggestions ? { suggestions } : {})} onSelect={text => { if (leaving) return; controller.setText(text); textarea.current?.focus(); }} />}\n </div></div>}\n </div>\n\n <footer data-testid=\"composer-dock\" className=\"shrink-0 px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, disabled: composer.disabled || leaving, stopOpen: leaving && !composer.disabled,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={leaving || state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
88
88
  "type": "registry:component",
89
89
  "target": "components/pi-chat/pi-chat.tsx"
90
90
  },
@@ -108,7 +108,7 @@
108
108
  },
109
109
  {
110
110
  "path": "registry/pi-chat/session.tsx",
111
- "content": "'use client';\n\nimport { useCallback, useMemo, useRef } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord, JsonObject } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { PAGE_FIRST, PAGE_MORE, useTranscriptWindow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatPaging } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';\nimport { Button } from '@/components/ui/button';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, SentContent, SlashConfig } from './config';\nimport type { PasteToFile } from '@hachej/boring-ui-kit/chat-text';\nimport type { CommandMentions } from './config';\nimport type { ChatCards, ChatTextPart, RowContext } from './message';\nimport type { QueuedMessage } from './queue';\nimport type { AnswerOutcome, ChatArtifactsConfig } from './config';\nimport type { ChatCard, Mode } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n /** `natural` shows only messages, replies and question, approval and artifact cards, with one \"Preparing…\" status while the agent works: no tool steps, reasoning, file discovery or raw error text. Omit it and nothing changes. */\n readonly presentation?: 'natural';\n /** Show a retained voice message's audio player. Default false: transcript only; uploaded audio and stored tokens are unchanged. */\n readonly voiceReplay?: boolean;\n /** The words on the chip of a sent message that starts with a `<target>` block (point-and-speak). Undefined keeps the default label; the chip never shows the raw fields on hover. */\n readonly targetLabel?: (target: JsonObject) => string | undefined;\n /** A host's own card for a sent text. */\n readonly sentContent?: SentContent;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n /** Omit complete assistant lines from display, copy and actions. Live trailing fragments wait for a newline or settlement. Not transport redaction. */\n readonly hideLine?: (line: string) => boolean;\n /** Initial number of displayed rows (default 60). An explicit limit uses Show earlier, not automatic paging; nothing is deleted. */\n readonly displayLimit?: number;\n /** Automatic paging: rows shown first (default 60) and rows added each time the reader reaches the top (default 40). Ignored with `displayLimit`. */\n readonly paging?: ChatPaging;\n /** Explicitly enable messageActions for paged history too; other historical controls remain inert. */\n readonly historicalMessageActions?: boolean;\n /** Host-rendered unread indicator, keyed by the reply. The host owns seen state and its persistence. */\n readonly unread?: (reply: ReplyRef) => ReactNode;\n /** Message inside the single native response-error notice. Null/undefined keeps the default; native status and metadata stay unchanged. */\n readonly replyError?: (reply: ReplyRef) => ReactNode;\n /** Card for any tool call; the catch-all after `toolRenderers`. */\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n /** Cards for tool calls, keyed by tool name (same contract as `renderTool`). An entry that returns undefined falls back to `renderTool`. */\n readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined>> | undefined;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /**\n * The question, approval and sub-agent cards (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`; `withChatPlugins` adds them all).\n * Without a question or approval card the chat shows that it waits for the person's answer, with no answer UI; without the sub-agent card a\n * sub-agent call is an ordinary tool step.\n */\n readonly cards?: ChatCards | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** Optional pills at the left of the composer's controls row. */\n readonly composerChips?: ComposerProps['chips'];\n /** Contextual host content above the input, for example a removable quote. */\n readonly composerHeader?: ReactNode;\n /** Host-owned content participates in send readiness without native image attachments. */\n readonly composerHasContent?: boolean;\n /** Retain files for the host's send operation instead of uploading immediately. */\n readonly onPickFiles?: (files: readonly File[]) => void;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Show successful native write/edit paths as file cards under each reply. Off by default: only hosts with workspace file tools should opt in. */\n readonly fileChanges?: boolean;\n /** Opens a file from an upload or write/edit card. Falls back to mentions.open. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /**\n * Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. The card and its recogniser\n * come from `pi-artifacts` (`withChatPlugins` adds them); without them, or without this prop, no artifact is offered.\n */\n readonly artifacts?: ChatArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\n// Compatibility type export for one release.\nexport type { FilesHandler } from '@hachej/boring-ui-kit/chat-react';\nimport { useChatSession as useHeadlessChatSession } from '@hachej/boring-ui-kit/chat-react';\nimport type { FilesHandler } from '@hachej/boring-ui-kit/chat-react';\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: ChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: ChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** The phone camera for an `onFiles` chat (`attachments.Camera` is used with `attachments`). */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional annotate toggle: when notes are pending (`count` > 0), Send attaches them to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback | undefined;\n}\n\n/** Presentation adapter; all session state and controller protocols live in the kit. */\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, sendContext, onOpenFile, fileChanges, composerChips, composerHeader, composerHasContent, onPickFiles, pasteToFile } = options;\n const { labels } = useChatText();\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const offered = artifacts?.recognise && artifacts.Card ? artifacts : undefined;\n const sessionArtifacts = useMemo(() => offered?.recognise ? { recognise: offered.recognise, detect: offered.detect, open: offered.open } : undefined, [offered]);\n const session = useHeadlessChatSession<ReactNode>({ controller, activeController, mode, presentation, actions, renderEntry, hideLine, renderTool, toolRenderers,\n groupTool, subagent: cards?.subagent, onOpenImage, onFiles, mentions, slash, attachments, model, effort,\n artifacts: sessionArtifacts,\n afterSend, feedback: sendContext ?? feedback, onOpenFile, onPickFiles, commandMentions, labels, onFocus: () => textarea.current?.focus() });\n const { state, developer, natural, connected, working, sendBlocked, act } = session;\n const rowContext = useMemo<RowContext>(() => ({\n developer, natural, voiceReplay, targetLabel, sentContent, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: session.activateMention } : undefined,\n answer: actions?.answer,\n artifacts: offered?.Card ? { open: offered.open, isOpen: offered.isOpen, kinds: offered.kinds, Card: offered.Card } : undefined,\n messageActions, historicalMessageActions, unread, replyError,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { kinds: mentions?.kinds, mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.renderSent ?? mentions?.render, preview: mentions?.preview, audioUrl: mentions?.audioUrl } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps -- lists the fields rows read, not whole objects: activateMention changes every render and must not rebuild every row.\n }), [controller, developer, natural, voiceReplay, targetLabel, sentContent, renderText, cards, groupTool, onOpenImage, onCopy, onOpenFile, fileChanges, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.renderSent, mentions?.preview, mentions?.audioUrl, attachments, offered?.open, offered?.isOpen, offered?.kinds, offered?.Card, messageActions, historicalMessageActions, unread, replyError, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = session.changing !== null || state.disposed || !connected;\n // The pill is drawn by the component the config carries (`pi-chat-pickers`); without one there is no pill.\n const Picker = model?.Picker ?? effort?.Picker;\n const barStart = (model || effort) && Picker ? <Picker model={model} effort={effort} currentModel={session.currentModel} currentEffort={session.currentEffort} disabled={disabled} busy={session.changing}\n onModel={next => void session.changeModel(next)}\n onEffort={next => void session.changeEffort(next)} /> : undefined;\n const barNote = session.changeError ? <Button variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${session.changeError} (${labels.dismissHint})`} onClick={session.dismissChangeError}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{session.changeError}</span></Button> : undefined;\n return { barStart, barNote };\n };\n\n const camera = attachments ? attachments.Camera : options.camera;\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n // A typed message answers the waiting question, so the primary button sends it.\n working: working && !session.answersQuestion, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: session.send, onStop: () => act(controller.stop),\n hasFiles: Boolean(composerHasContent),\n uploading: session.uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(camera ? { camera } : {}), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void session.upload(files); },\n slash, onSlashError: session.report, mentions, mentionPaths: session.picked, onMentionPicked: session.pickMention,\n uploads: session.pending, onDismissUpload: session.dismissUpload,\n onPasteText: attachments ? session.pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { ...session, textarea, rowContext, composer, pickers };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n// Compatibility exports for one release; MessageScroller remains the only scroll owner.\nexport { chunkKeys, useRowChunks, PAGE_FIRST, PAGE_MORE, CHUNK } from '@hachej/boring-ui-kit/chat-react';\nexport type { ChatPaging } from '@hachej/boring-ui-kit/chat-react';\n\n/** Thin presentation adapter to the host's shadcn MessageScroller. */\nexport function useTranscript(rows: readonly { readonly key: string }[], page = PAGE_FIRST, more = PAGE_MORE, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }, automatic = true) {\n const window = useTranscriptWindow(rows, page, more, history, automatic);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n return { ...window, stick: { scrollRef: window.scrollRef, scrollToBottom, isAtBottom: !scrollable.end } };\n}\n",
111
+ "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useRef } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord, JsonObject } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { PAGE_FIRST, PAGE_MORE, useTranscriptWindow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatPaging } from '@hachej/boring-ui-kit/chat-react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport type { ChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useMessageScroller, useMessageScrollerScrollable } from '@/components/ui/message-scroller';\nimport { Button } from '@/components/ui/button';\nimport type { ComposerFeedback, ComposerProps } from './composer';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, SentContent, SlashConfig } from './config';\nimport type { PasteToFile } from '@hachej/boring-ui-kit/chat-text';\nimport type { CommandMentions } from './config';\nimport type { ChatCards, ChatTextPart, RowContext } from './message';\nimport type { QueuedMessage } from './queue';\nimport type { AnswerOutcome, ChatArtifactsConfig } from './config';\nimport type { ChatCard, Mode } from './rows';\nimport { useChatText } from './labels';\nimport type { BlockAction } from '../button/actions';\nimport type { ReplyRef } from './config';\n\n/** What the host lets the person do beyond typing. Pass `remote.answer` and `remote.withdraw` from `createRemoteChat()`. */\nexport interface PiChatActions {\n /** Resolve a pending `ask_user` question. Without it, question cards are read-only. */\n readonly answer?: (questionId: string, answer: string) => Promise<AnswerOutcome>;\n /** Withdraw a queued message before Pi places it. Without it, queued messages cannot be cancelled. */\n readonly withdraw?: (id: QueuedMessage['id']) => Promise<unknown>;\n}\n\n/** The props `PiChat` and `AmbientChat` both take and hand to `useChatSession`. */\nexport interface ChatFeatureProps {\n /** `expert` hides successful tool details and reasoning; `developer` shows everything. */\n readonly mode?: Mode;\n /** `natural` shows only messages, replies and question, approval and artifact cards, with one \"Preparing…\" status while the agent works: no tool steps, reasoning, file discovery or raw error text. Omit it and nothing changes. */\n readonly presentation?: 'natural';\n /** Show a retained voice message's audio player. Default false: transcript only; uploaded audio and stored tokens are unchanged. */\n readonly voiceReplay?: boolean;\n /** The words on the chip of a sent message that starts with a `<target>` block (point-and-speak). Undefined keeps the default label; the chip never shows the raw fields on hover. */\n readonly targetLabel?: (target: JsonObject) => string | undefined;\n /** A host's own card for a sent text. */\n readonly sentContent?: SentContent;\n readonly actions?: PiChatActions;\n readonly renderEntry?: (entry: EntryRecord) => ChatCard | undefined;\n /** Omit complete assistant lines from display, copy and actions. Live trailing fragments wait for a newline or settlement. Not transport redaction. */\n readonly hideLine?: (line: string) => boolean;\n /** Initial number of displayed rows (default 60). An explicit limit uses Show earlier, not automatic paging; nothing is deleted. */\n readonly displayLimit?: number;\n /** Automatic paging: rows shown first (default 60) and rows added each time the reader reaches the top (default 40). Ignored with `displayLimit`. */\n readonly paging?: ChatPaging;\n /** Explicitly enable messageActions for paged history too; other historical controls remain inert. */\n readonly historicalMessageActions?: boolean;\n /** Host-rendered unread indicator, keyed by the reply. The host owns seen state and its persistence. */\n readonly unread?: (reply: ReplyRef) => ReactNode;\n /** Message inside the single native response-error notice. Null/undefined keeps the default; native status and metadata stay unchanged. */\n readonly replyError?: (reply: ReplyRef) => ReactNode;\n /** Card for any tool call; the catch-all after `toolRenderers`. */\n readonly renderTool?: (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined;\n /** Cards for tool calls, keyed by tool name (same contract as `renderTool`). An entry that returns undefined falls back to `renderTool`. */\n readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined>> | undefined;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /**\n * The question, approval and sub-agent cards (`pi-chat-question`, `pi-chat-approval`, `pi-chat-subagent`; `withChatPlugins` adds them all).\n * Without a question or approval card the chat shows that it waits for the person's answer, with no answer UI; without the sub-agent card a\n * sub-agent call is an ordinary tool step.\n */\n readonly cards?: ChatCards | undefined;\n readonly groupTool?: (call: ToolCall, result: ToolResultMessage) => boolean;\n readonly commandMentions?: CommandMentions;\n readonly onOpenImage?: (image: ImageContent) => void;\n readonly onCopy?: (text: string) => Promise<void>;\n readonly onComposerKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n readonly fileAccept?: string;\n /** Optional pills at the left of the composer's controls row. */\n readonly composerChips?: ComposerProps['chips'];\n /** Contextual host content above the input, for example a removable quote. */\n readonly composerHeader?: ReactNode;\n /** Host-owned content participates in send readiness without native image attachments. */\n readonly composerHasContent?: boolean;\n /** Retain files for the host's send operation instead of uploading immediately. */\n readonly onPickFiles?: (files: readonly File[]) => void;\n /** `/` menu of host commands and skills. Omit it and `/` is just text. */\n readonly slash?: SlashConfig;\n /** Show successful native write/edit paths as file cards under each reply. Off by default: only hosts with workspace file tools should opt in. */\n readonly fileChanges?: boolean;\n /** Opens a file from an upload or write/edit card. Falls back to mentions.open. */\n readonly onOpenFile?: (path: string) => void;\n /** Pastes above these limits become a file through `attachments` (on by default, 2000 characters or 40 lines). Needs `attachments`. */\n readonly pasteToFile?: PasteToFile;\n /** `@` workspace file mentions: a picker, composer chips and styled mentions in sent messages. */\n readonly mentions?: MentionsConfig;\n /** Attach button, paste and drop through the host's upload. Takes the place of `onFiles` and `fileAccept`. */\n readonly attachments?: AttachmentsConfig;\n /** Model picker in the composer bar. The current model comes from the view's `pi.agent` document. */\n readonly model?: ModelConfig;\n /** Thinking level picker in the composer bar. The current level comes from the view's `pi.agent` document. */\n readonly effort?: EffortConfig;\n /**\n * Artifact cards: tool results that carry a descriptor become a card that opens the artifact in the host's panel. The card and its recogniser\n * come from `pi-artifacts` (`withChatPlugins` adds them); without them, or without this prop, no artifact is offered.\n */\n readonly artifacts?: ChatArtifactsConfig;\n /**\n * Host actions under each settled reply, beside Copy (and Fork): `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids\n * `reply-action-<id>`, the menu `reply-action-more`.\n */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n /** A line under each settled reply (for example the model that answered and the time it took), for the host to show to some people only. */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n}\n\n\n// Compatibility type export for one release.\nexport type { FilesHandler } from '@hachej/boring-ui-kit/chat-react';\nimport { useChatSession as useHeadlessChatSession } from '@hachej/boring-ui-kit/chat-react';\nimport type { FilesHandler } from '@hachej/boring-ui-kit/chat-react';\n\n/** The chat behaviour every surface over a `NativeChatController` shares (`PiChat`, `AmbientChat`): sending, queue actions, uploads, pickers, rows. */\nexport interface ChatSessionOptions extends Readonly<{ [Key in keyof ChatFeatureProps]: ChatFeatureProps[Key] | undefined }> {\n readonly controller: ChatController;\n /** The controller the host currently passes; a surface that outlives its controller stops acting on the old one. */\n readonly activeController: { readonly current: ChatController };\n readonly mode: Mode;\n readonly fileAccept: string;\n readonly onFiles?: FilesHandler | undefined;\n /** The phone camera for an `onFiles` chat (`attachments.Camera` is used with `attachments`). */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Runs after a message was accepted for sending (for example to scroll to the bottom). */\n readonly afterSend?: (() => void) | undefined;\n /** Extra attributes of the row context a surface needs (for example per-reply actions). */\n readonly rowExtras?: Partial<RowContext> | undefined;\n /** The optional annotate toggle: when notes are pending (`count` > 0), Send attaches them to the message first. */\n readonly feedback?: ComposerFeedback | undefined;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback | undefined;\n}\n\n/** After New or fork the cursor goes to the new conversation's box, unless the person moved to another field. Not on a phone, where it would open the keyboard. */\nexport function useCursorInOpened(textarea: { readonly current: HTMLTextAreaElement | null }, conversations: { readonly opened?: string | undefined; readonly activeId?: string | undefined } | undefined, disabled: boolean) {\n const opened = conversations?.opened, activeId = conversations?.activeId;\n const done = useRef<string | undefined>(undefined);\n useEffect(() => {\n if (opened === undefined || opened !== activeId || disabled || done.current === opened) return;\n done.current = opened;\n if (globalThis.matchMedia?.('(pointer: coarse)').matches) return;\n if (globalThis.document?.activeElement?.matches('input, textarea, select, [contenteditable]')) return;\n textarea.current?.focus();\n }, [textarea, opened, activeId, disabled]);\n}\n\n/** Presentation adapter; all session state and controller protocols live in the kit. */\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController, mode, presentation, voiceReplay, targetLabel, sentContent, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, sendContext, onOpenFile, fileChanges, composerChips, composerHeader, composerHasContent, onPickFiles, pasteToFile } = options;\n const { labels } = useChatText();\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const offered = artifacts?.recognise && artifacts.Card ? artifacts : undefined;\n const sessionArtifacts = useMemo(() => offered?.recognise ? { recognise: offered.recognise, detect: offered.detect, open: offered.open } : undefined, [offered]);\n const session = useHeadlessChatSession<ReactNode>({ controller, activeController, mode, presentation, actions, renderEntry, hideLine, renderTool, toolRenderers,\n groupTool, subagent: cards?.subagent, onOpenImage, onFiles, mentions, slash, attachments, model, effort,\n artifacts: sessionArtifacts,\n afterSend, feedback: sendContext ?? feedback, onOpenFile, onPickFiles, commandMentions, labels, onFocus: () => textarea.current?.focus() });\n const { state, developer, natural, connected, working, sendBlocked, act } = session;\n const rowContext = useMemo<RowContext>(() => ({\n developer, natural, voiceReplay, targetLabel, sentContent, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: session.activateMention } : undefined,\n answer: actions?.answer,\n artifacts: offered?.Card ? { open: offered.open, isOpen: offered.isOpen, kinds: offered.kinds, Card: offered.Card } : undefined,\n messageActions, historicalMessageActions, unread, replyError,\n replyFooter,\n pieces: slash?.skills?.length || mentions || attachments ? { kinds: mentions?.kinds, mentions: Boolean(mentions), uploads: Boolean(attachments), skills: (slash?.skills ?? []).map(skill => skill.name), openMention: mentions?.open, renderMention: mentions?.renderSent ?? mentions?.render, preview: mentions?.preview, audioUrl: mentions?.audioUrl } : undefined,\n ...rowExtras,\n // eslint-disable-next-line react-hooks/exhaustive-deps -- lists the fields rows read, not whole objects: activateMention changes every render and must not rebuild every row.\n }), [controller, developer, natural, voiceReplay, targetLabel, sentContent, renderText, cards, groupTool, onOpenImage, onCopy, onOpenFile, fileChanges, commandMentions, actions?.answer, slash?.skills, mentions, mentions?.open, mentions?.render, mentions?.renderSent, mentions?.preview, mentions?.audioUrl, attachments, offered?.open, offered?.isOpen, offered?.kinds, offered?.Card, messageActions, historicalMessageActions, unread, replyError, replyFooter, rowExtras]);\n\n /** The model-and-effort pill and the note that reports a refused change. */\n const pickers = () => {\n const disabled = session.changing !== null || state.disposed || !connected;\n // The pill is drawn by the component the config carries (`pi-chat-pickers`); without one there is no pill.\n const Picker = model?.Picker ?? effort?.Picker;\n const barStart = (model || effort) && Picker ? <Picker model={model} effort={effort} currentModel={session.currentModel} currentEffort={session.currentEffort} disabled={disabled} busy={session.changing}\n onModel={next => void session.changeModel(next)}\n onEffort={next => void session.changeEffort(next)} /> : undefined;\n const barNote = session.changeError ? <Button variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${session.changeError} (${labels.dismissHint})`} onClick={session.dismissChangeError}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{session.changeError}</span></Button> : undefined;\n return { barStart, barNote };\n };\n\n const camera = attachments ? attachments.Camera : options.camera;\n /** Everything `Composer` needs from the session; a surface adds its layout and slots. */\n const composer = {\n text: state.draft.text, onText: (text: string) => controller.setText(text), onKeyDown: onComposerKeyDown, textareaRef: textarea,\n attachments: state.draft.attachments.map(item => ({ id: item.id, name: item.name, mimeType: item.content.mimeType, data: item.content.data })),\n onRemoveAttachment: (id: string) => controller.setAttachments(controller.getSnapshot().draft.attachments.filter(item => item.id !== id)),\n // A typed message answers the waiting question, so the primary button sends it.\n working: working && !session.answersQuestion, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: session.send, onStop: () => act(controller.stop),\n hasFiles: Boolean(composerHasContent),\n uploading: session.uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(camera ? { camera } : {}), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void session.upload(files); },\n slash, onSlashError: session.report, mentions, mentionPaths: session.picked, onMentionPicked: session.pickMention,\n uploads: session.pending, onDismissUpload: session.dismissUpload,\n onPasteText: attachments ? session.pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { ...session, textarea, rowContext, composer, pickers };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n// Compatibility exports for one release; MessageScroller remains the only scroll owner.\nexport { chunkKeys, useRowChunks, PAGE_FIRST, PAGE_MORE, CHUNK } from '@hachej/boring-ui-kit/chat-react';\nexport type { ChatPaging } from '@hachej/boring-ui-kit/chat-react';\n\n/** Thin presentation adapter to the host's shadcn MessageScroller. */\nexport function useTranscript(rows: readonly { readonly key: string }[], page = PAGE_FIRST, more = PAGE_MORE, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }, automatic = true) {\n const window = useTranscriptWindow(rows, page, more, history, automatic);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n return { ...window, stick: { scrollRef: window.scrollRef, scrollToBottom, isAtBottom: !scrollable.end } };\n}\n",
112
112
  "type": "registry:component",
113
113
  "target": "components/pi-chat/session.tsx"
114
114
  },
@@ -8,9 +8,9 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/dock.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/dock.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -9,10 +9,10 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/pi-app.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/pi-ambient.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.54/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/pi-app.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/pi-ambient.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.56/r/utils.json"
16
16
  ],
17
17
  "files": [
18
18
  {