@hachej/boring-ui-kit 0.4.15 → 0.4.17

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/shared-chat/index.js +11 -5
  2. package/package.json +2 -2
  3. package/r/agent-target.json +4 -4
  4. package/r/app-shell.json +10 -10
  5. package/r/board-viewer.json +5 -5
  6. package/r/button.json +1 -1
  7. package/r/diagram-viewer.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/feedback-preview.json +1 -1
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +5 -5
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +1 -1
  16. package/r/maquette-viewer.json +2 -2
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +10 -10
  20. package/r/pi-app.json +20 -20
  21. package/r/pi-artifacts.json +1 -1
  22. package/r/pi-chat-approval.json +1 -1
  23. package/r/pi-chat-attachments.json +1 -1
  24. package/r/pi-chat-markdown.json +1 -1
  25. package/r/pi-chat-mentions.json +1 -1
  26. package/r/pi-chat-pickers.json +1 -1
  27. package/r/pi-chat-plugins.json +11 -11
  28. package/r/pi-chat-question.json +1 -1
  29. package/r/pi-chat-slash.json +1 -1
  30. package/r/pi-chat-subagent.json +1 -1
  31. package/r/pi-chat-voice.json +2 -2
  32. package/r/pi-chat.json +9 -9
  33. package/r/pi-workspace.json +4 -4
  34. package/r/placed-agent.json +4 -4
  35. package/r/provider-setup.json +2 -2
  36. package/r/record-viewer.json +1 -1
  37. package/r/registry.json +166 -166
  38. package/r/requests-board.json +1 -1
  39. package/r/shared-chat.json +12 -12
  40. package/r/task-list-viewer.json +2 -2
  41. package/r/version-history.json +3 -3
  42. package/r/viewer-code.json +2 -2
  43. package/r/viewer-frame.json +5 -5
  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,9 +4,9 @@
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.15",
8
- "@hachej/boring-files@^0.4.15",
9
- "@hachej/boring-feedback@^0.4.15",
7
+ "@hachej/boring-ui-kit@^0.4.17",
8
+ "@hachej/boring-files@^0.4.17",
9
+ "@hachej/boring-feedback@^0.4.17",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "@base-ui/react@1.9.0",
@@ -19,8 +19,8 @@
19
19
  "zod@^4.6.5"
20
20
  ],
21
21
  "registryDependencies": [
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.17/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.17/r/button.json",
24
24
  "dialog",
25
25
  "tooltip",
26
26
  "trace",
@@ -113,7 +113,7 @@
113
113
  },
114
114
  {
115
115
  "path": "registry/pi-chat/message.tsx",
116
- "content": "'use client';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PauseIcon, PlayIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport type { AnswerOutcome, ChatArtifactsConfig, ChatCards, CommandMentions } from './config';\nimport { feedbackContent } from './feedback-content';\nimport { SentFeedback } from './sent-feedback';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, pieces, visibleParts } from './config';\nimport type { AudioUrlRead, FileKind, Piece, PreviewRead } from './config';\nimport { voiceClock } from './config';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '@/components/ui/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\nimport type { ReplyRef } from './config';\nimport { Message, MessageContent, MessageFooter } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from '@/components/ui/attachment';\nimport { Badge } from '@/components/ui/badge';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\n\n/** One assistant text part, as `renderText` receives it. `streaming` is true while the model still writes it. */\nexport interface ChatTextPart {\n readonly text: string;\n readonly streaming: boolean;\n /** Draws an `@path` mention (when mentions are on). */\n readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined;\n /** The `/` commands a settled reply may turn into buttons. */\n readonly commandMentions?: CommandMentions | undefined;\n readonly onCopy?: ((text: string) => Promise<void>) | undefined;\n}\n/** The default text renderer: the reply as plain text. `markdownText` of `pi-chat-markdown` draws it as Markdown. */\nconst plainText = ({ text, streaming }: ChatTextPart): ReactNode =>\n <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport type { ChatCards } from './config';\n\nexport interface RowContext {\n readonly developer: boolean;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n readonly cards?: ChatCards | undefined;\n /** `presentation=\"natural\"`: no \"working\" shimmer in a reply (the chat shows one status instead). */\n readonly natural?: boolean | undefined;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n /** Host openers keep their single image argument; the workspace's default also accepts the message gallery. */\n readonly onOpenImage: ((image: ImageContent, gallery?: readonly ImageContent[]) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined; readonly audioUrl?: AudioUrlRead | undefined } | undefined;\n readonly fileChanges?: boolean | undefined;\n /** Opens a file card through the host's workspace opener. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ChatArtifactsConfig['open']; readonly isOpen?: ChatArtifactsConfig['isOpen'] | undefined; readonly kinds?: ChatArtifactsConfig['kinds']; readonly Card: NonNullable<ChatArtifactsConfig['Card']> } | undefined;\n /** Replaces the default copy button under a settled reply (the ambient window adds feedback and the time); `host` holds the host's message actions. */\n readonly replyActions?: ((reply: { readonly key: string; readonly text: string; readonly timestamp?: number | undefined }, host: ReactNode) => ReactNode) | undefined;\n /** The host's actions for a settled reply (`messageActions`). */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n readonly historicalMessageActions?: boolean | undefined;\n /** Host-owned per-answer unread indicator; rendering never marks an answer seen. */\n readonly unread?: ((reply: ReplyRef) => ReactNode) | undefined;\n readonly replyError?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction ImageThumbnails({ images, onOpenImage }: { readonly images: readonly ImageContent[]; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n if (!images.length) return null;\n return <div data-testid=\"message-images\" data-slot=\"message-images\" className={cn('grid max-w-full gap-2', images.length === 1 ? 'w-60' : 'w-120 grid-cols-2')}>\n {images.map((image, at) => <div key={at} className=\"relative min-h-12 min-w-12 self-start\">\n <img src={thumbnail(image.mimeType, image.data)} alt={labels.imageAttachment} className=\"block h-auto w-full rounded-(--radius-xl)\" />\n <Button variant=\"link\" className=\"absolute inset-0 h-auto min-h-12 min-w-12 w-full\" data-testid=\"message-image\" aria-label={images.length > 1 ? `${labels.openImage}, ${labels.imagePosition(at + 1, images.length)}` : labels.openImage} onClick={() => onOpenImage?.(image, images)} />\n </div>)}\n </div>;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <span role=\"link\" tabIndex={0} className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); open(path); } }}>{text}</span>\n : <span className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path}>{text}</span>;\n}\n\n/** The icon a file card shows for its kind. */\nfunction FileGlyph({ kind }: { readonly kind: FileKind }) {\n switch (kind) {\n case 'image': return <ImageIcon aria-hidden=\"true\" />;\n case 'sheet': return <FileSpreadsheetIcon aria-hidden=\"true\" />;\n case 'audio': return <FileAudioIcon aria-hidden=\"true\" />;\n case 'slides': return <PresentationIcon aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon aria-hidden=\"true\" />;\n default: return <FileIcon aria-hidden=\"true\" />;\n }\n}\n\n/** A file a person sent (an `uploads/` mention): its icon or thumbnail, name and type. A button when it can be opened. */\nfunction FileCard({ path, open, preview, change }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined; readonly change?: 'created' | 'edited' }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = change ? path.split('/').pop() || path : displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n return <Attachment className=\"max-w-64\" {...(!open ? { 'data-testid': 'message-mention', 'data-path': path } : {})}>\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt=\"\" /> : <FileGlyph kind={kind} />}</AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>{name}</AttachmentTitle>\n <AttachmentDescription>{change ? labels.fileChange[change] : labels.fileKind[kind]}</AttachmentDescription>\n </AttachmentContent>\n {open && <AttachmentTrigger data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)} />}\n </Attachment>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nexport function VoiceBubble({ path, transcript, preview, audioUrl, align = 'end' }: { readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const audio = useRef<HTMLAudioElement>(null), work = useRef<AbortController | null>(null), loadingRequest = useRef(false);\n const [playing, setPlaying] = useState(false), [loading, setLoading] = useState(false), [failed, setFailed] = useState(false), [duration, setDuration] = useState<number>();\n const source = useRef<string | undefined>(undefined), loader = useRef<() => Promise<boolean>>(async () => false);\n useEffect(() => {\n const element = audio.current;\n let objectUrl: string | undefined;\n source.current = undefined; loadingRequest.current = false; setPlaying(false); setLoading(false); setFailed(false); setDuration(undefined);\n const release = () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = undefined; } };\n const abort = new AbortController(); work.current = abort;\n const load = async () => {\n release();\n const signed = await audioUrl?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (signed) {\n const url = new URL(signed, window.location.href);\n if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Unsupported audio URL');\n source.current = url.href;\n } else {\n const read = await preview?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (!read || !/^audio\\/(?:webm|ogg|mp4|m4a|x-m4a|mpeg|mp3|wav|x-wav)(?:;|$)/i.test(read.mimeType)) throw new Error('Audio unavailable');\n const bytes = Uint8Array.from(atob(read.data), character => character.charCodeAt(0));\n // MediaRecorder WebM often has no finite metadata duration; decode only these already-requested bytes.\n try {\n if (typeof OfflineAudioContext === 'function') void new OfflineAudioContext(1, 1, 48_000).decodeAudioData(bytes.slice().buffer).then(buffer => {\n if (!abort.signal.aborted && Number.isFinite(buffer.duration)) setDuration(Math.round(buffer.duration));\n }).catch(() => {});\n } catch { /* Optional duration decoding must not prevent native playback. */ }\n objectUrl = URL.createObjectURL(new Blob([bytes], { type: read.mimeType })); source.current = objectUrl;\n }\n if (element && source.current) element.src = source.current;\n };\n // A loader belongs to this path/provider epoch, not to a later row with the same DOM node.\n loader.current = async () => { await load(); return !abort.signal.aborted; };\n return () => { abort.abort(); if (work.current === abort) work.current = null; element?.pause(); element?.removeAttribute('src'); element?.load(); source.current = undefined; release(); };\n }, [path, preview, audioUrl]);\n const toggle = async () => {\n const element = audio.current, abort = work.current;\n if (!element || !abort || loadingRequest.current) return;\n if (!element.paused) { element.pause(); return; }\n loadingRequest.current = true; setFailed(false); setLoading(true);\n try {\n if (!source.current && !await loader.current()) return;\n if (!abort.signal.aborted) await element.play();\n } catch { if (!abort.signal.aborted) setFailed(true); }\n finally { if (work.current === abort && !abort.signal.aborted) { loadingRequest.current = false; setLoading(false); } }\n };\n return <Bubble variant=\"muted\" align={align} data-testid=\"voice-bubble\"><BubbleContent>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-play\" aria-label={playing ? labels.voicePause : labels.voicePlay} disabled={loading || (!preview && !audioUrl)} onClick={() => { void toggle(); }}>\n {loading ? <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" /> : playing ? <PauseIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <PlayIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n <span data-testid=\"voice-duration\">{duration === undefined ? '--:--' : voiceClock(duration)}</span>\n <audio ref={audio} preload=\"none\" onPlay={() => { setPlaying(true); setFailed(false); }} onPause={() => setPlaying(false)} onEnded={() => {\n setPlaying(false); const element = audio.current;\n if (element && !Number.isFinite(element.duration) && Number.isFinite(element.currentTime)) setDuration(Math.round(element.currentTime));\n }} onError={() => { setFailed(true); setPlaying(false); source.current = undefined; }} onDurationChange={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} onLoadedMetadata={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} />\n </div>\n {failed && <p role=\"alert\">{labels.voicePlayFailed}</p>}\n {transcript && <div data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{transcript}</div>}\n </BubbleContent></Bubble>;\n}\n\n/**\n * A text part of a sent message. Its `uploads/` mentions become file cards above the bubble and leave the text; the message content keeps\n * them (only what is drawn changes).\n */\n/** `variant` picks the bubble: `muted` (the chat's user bubble) or `default` (the theme's primary, used for my own messages in a shared chat). */\n/** `decoration` sits inside the Bubble before its content (a tail); `header` and `footer` inside the content, around the text (a quote, the time). */\nexport function MessageText({ text, context, align = 'end', variant = 'muted', decoration, header, footer }: { readonly text: string; readonly context: Pick<RowContext, 'pieces' | 'openFile'>; readonly align?: 'start' | 'end'; readonly variant?: 'muted' | 'default'; readonly decoration?: ReactNode; readonly header?: ReactNode; readonly footer?: ReactNode }) {\n const framed = decoration !== undefined || header !== undefined || footer !== undefined;\n const wrap = (body: ReactNode) => <Bubble variant={variant} align={align}>{decoration}<BubbleContent data-testid=\"user-text\" className={framed ? 'relative' : undefined}>{header}{body}{footer}</BubbleContent></Bubble>;\n const target = targetContent(text);\n if (target) return <>\n <Badge variant=\"secondary\" data-testid=\"message-target\" title={JSON.stringify(target.target)} className=\"max-w-full truncate\">{target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} decoration={decoration} header={header} footer={footer} />\n </>;\n const feedback = feedbackContent(text);\n if (feedback) return <>\n {feedback.before && <MessageText text={feedback.before} context={context} align={align} variant={variant} />}\n <SentFeedback report={feedback.report} align={align} />\n {feedback.after && <MessageText text={feedback.after} context={context} align={align} variant={variant} />}\n </>;\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n const styledUploads = Boolean(context.pieces?.mentions || context.pieces?.uploads);\n const all = pieces(text, { mentions: context.pieces?.mentions ?? false, uploads: true, skills: context.pieces?.skills ?? [] }).map((piece): Piece =>\n !styledUploads && piece.kind === 'mention' && isUploadPath(piece.value!) && !isVoicePath(piece.value!) ? { text: piece.text } : piece);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const voice = files.find(isVoicePath), cards = files.filter(path => path !== voice);\n if (!context.pieces && !voice) return wrap(<span className=\"whitespace-pre-wrap\">{text}</span>);\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n const content = shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <Badge key={at} variant=\"secondary\" data-testid=\"message-skill\" data-skill={piece.value}>{piece.text}</Badge>\n : piece.text);\n return <>\n {cards.length > 0 && <FileCards paths={cards} open={context.openFile ?? open} preview={context.pieces?.preview} />}\n {voice ? <VoiceBubble path={voice} transcript={empty ? null : content} preview={context.pieces?.preview} audioUrl={context.pieces?.audioUrl} align={align} />\n : !(files.length > 0 && empty) && wrap(<span className=\"whitespace-pre-wrap\">{content}</span>)}\n </>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end' }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end' }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <Message align={align}><MessageContent>\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <MessageText key={index} text={part.text} context={context} align={align} />)\n : null)}\n </MessageContent></Message>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}>\n {icon}<AlertDescription>{children}</AlertDescription>\n </Alert>;\n}\n\n/**\n * A question or an approval waits for the person but this chat has no card for it (no `pi-chat-question` or `pi-chat-approval`): say so,\n * so the run is never silently blocked. It offers no answer UI.\n */\nfunction AnswerWaiting({ kind }: { readonly kind: 'question' | 'approval' }) {\n const { labels } = useChatText();\n return <Alert role=\"status\" data-testid=\"answer-waiting\" data-kind={kind}>\n <AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" /><AlertDescription>{labels.waitingForAnswer}</AlertDescription>\n </Alert>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nfunction messageImages(row: Row): readonly ImageContent[] {\n const images = (content: ToolResultMessage['content']) => content.flatMap(part => part.type === 'image' && thumbnail(part.mimeType, part.data) ? [part] : []);\n if (row.type === 'user') return typeof row.message.content === 'string' ? [] : images(row.message.content);\n if (row.type === 'orphan-result') return images(row.message.content);\n return row.type === 'assistant' ? row.images?.filter(image => thumbnail(image.mimeType, image.data)) ?? row.parts.flatMap(part => part.kind === 'tool' && part.entry.result ? images(part.entry.result.content) : []) : [];\n}\n\nexport function RowView({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n const images = messageImages(row);\n const [selection, setSelection] = useState<{ readonly index: number; readonly images: readonly ImageContent[]; readonly key: string; readonly opener: RowContext['onOpenImage'] }>();\n const current = selection && selection.key === row.key && selection.opener === context.onOpenImage && selection.images.length === images.length\n && selection.images.every((image, at) => image.mimeType === images[at]?.mimeType && image.data === images[at]?.data) ? selection : undefined;\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!thumbnail(image.mimeType, image.data)) return;\n if (context.onOpenImage) { context.onOpenImage(image, images); return; }\n const index = images.indexOf(image);\n if (index >= 0) setSelection({ index, images, key: row.key, opener: context.onOpenImage });\n };\n return <><RowBody row={row} images={images} context={{ ...context, onOpenImage: openImage }} />\n {current && <ImageViewer images={images} index={current.index} onIndexChange={index => setSelection({ ...current, index })} onClose={() => setSelection(undefined)} />}\n </>;\n}\n\nfunction RowBody({ row, images, context: liveContext }: { readonly row: Row; readonly images: readonly ImageContent[]; readonly context: RowContext }) {\n const context: RowContext = row.historical ? { ...liveContext, answer: undefined, onFork: undefined, messageActions: liveContext.historicalMessageActions ? liveContext.messageActions : undefined, replyActions: undefined, commandMentions: undefined } : liveContext;\n const { labels, icons } = useChatText();\n const renderMention = useMemo(() => context.pieces?.mentions ? (path: string, text: string) => <UserMention path={path} text={text} open={context.pieces?.openMention} render={context.pieces?.renderMention} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" aria-live={row.historical ? 'off' : undefined}><UserBubble message={row.message} images={images} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><Collapsible>\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{labels.systemContext}</CollapsibleTrigger>\n <CollapsibleContent><pre className=\"whitespace-pre-wrap\">{row.text}</pre></CollapsibleContent>\n </Collapsible></article>;\n case 'orphan-result': return <article data-row-id={row.key} data-role=\"toolResult\"><Notice tone=\"error\" testid=\"tool-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>\n <strong className=\"font-semibold\">{labels.toolFailed(row.message.toolName)}</strong><span className=\"mt-1 block font-mono text-xs whitespace-pre-wrap\">{resultText(row.message)}</span></Notice><ImageThumbnails images={images} onOpenImage={context.onOpenImage} /></article>;\n case 'assistant': {\n const settled = !row.streaming;\n const nativeActions = settled;\n const files = new Map<string, 'created' | 'edited'>();\n if (context.fileChanges) for (const part of row.parts) {\n if (part.kind !== 'tool' || part.entry.status !== 'completed') continue;\n const { call, result } = part.entry;\n const path = call.arguments['path'];\n if (!result || result.isError || result.toolName !== call.name || (call.name !== 'write' && call.name !== 'edit') || typeof path !== 'string' || !path.trim()) continue;\n const normalized = path.replace(/^(\\.\\/)+/, '');\n if (normalized) files.set(normalized, files.get(normalized) === 'created' || call.name === 'write' ? 'created' : 'edited');\n }\n const chunks = segments(row.parts, context.groupTool);\n const { question: Question, approval: Approval } = context.cards ?? {};\n const active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = nativeActions && row.text && context.messageActions\n ? <BlockActions iconOnly actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} />\n : null;\n const unread = nativeActions && row.text ? context.unread?.(replyRef(row)) : null;\n const footer = nativeActions && !row.active && row.text ? context.replyFooter?.(replyRef(row)) : null;\n return <article data-row-id={row.key} data-role=\"assistant\" aria-live={row.historical ? 'off' : undefined} data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <Message><MessageContent>\n <div className=\"flex flex-col gap-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'card') return <div key={part.key}>{part.content}</div>;\n if (part.kind === 'text') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{(context.renderText ?? plainText)({ text: part.text, renderMention, streaming: row.streaming && part === lastText && row.parts[row.parts.length - 1] === part,\n ...(settled && context.commandMentions ? { commandMentions: context.commandMentions } : {}), ...(context.onCopy ? { onCopy: context.onCopy } : {}) })}</BubbleContent></Bubble>;\n if (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return Approval ? <Approval key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />\n : !part.live && !part.result && !part.decision ? <AnswerWaiting key={part.key} kind=\"approval\" /> : null;\n if (part.kind === 'subagent') return <Fragment key={part.key}>{part.content}</Fragment>;\n if (part.kind === 'question') return Question ? <Question key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />\n : !part.live && !part.result ? <AnswerWaiting key={part.key} kind=\"question\" /> : null;\n if (part.kind === 'artifact') {\n const { artifact } = part, artifacts = context.artifacts;\n if (!artifacts || (artifact.path !== undefined && files.has(artifact.path.replace(/^(\\.\\/)+/, '')))) return null;\n const descriptor = artifact.descriptor as never;\n return <artifacts.Card key={part.key} artifact={artifact} open={artifact.state === 'ready' && (artifacts.isOpen?.(descriptor) ?? false)}\n onOpen={artifact.state === 'ready' && artifacts.open ? () => artifacts.open(descriptor) : undefined} kinds={artifacts.kinds} />;\n }\n return part.entry.custom ? <div key={part.key}>{part.entry.custom}</div> : <ToolCard key={part.key} entry={part.entry} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n })}\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {files.size > 0 && <ul data-testid=\"reply-files\" className=\"m-0 flex list-none flex-wrap gap-2 p-0\">\n {[...files].map(([path, change]) => <li key={path} data-testid=\"message-file\"><FileCard path={path} change={change} open={context.openFile} preview={context.pieces?.preview} /></li>)}\n </ul>}\n {row.streaming && row.parts.length === 0 && !context.natural && <p data-testid=\"working\" className=\"m-0 text-sm\"><Shimmer>{labels.thinking}</Shimmer></p>}\n </div>\n {row.stopReason === 'error' && <Notice tone=\"error\" testid=\"response-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{context.replyError?.(replyRef(row)) ?? row.errorMessage ?? labels.responseFailed}</Notice>}\n {row.stopReason === 'aborted' && <Notice tone=\"muted\" testid=\"interrupted\" icon={<CircleSlashIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{labels.interrupted}</Notice>}\n {unread != null && unread !== false && <div data-testid=\"reply-unread\" className=\"flex flex-col items-start\">{unread}</div>}\n {nativeActions && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {nativeActions && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton iconOnly text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n onClick={() => context.onFork!(row.entryId!)}><icons.fork data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
116
+ "content": "'use client';\n\nimport { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ImageContent, ToolResultMessage, UserMessage } from '@earendil-works/pi-ai';\nimport { AlertCircleIcon, CircleSlashIcon, FileAudioIcon, FileIcon, FileSpreadsheetIcon, FileTextIcon, ImageIcon, PauseIcon, PlayIcon, PresentationIcon } from 'lucide-react';\nimport { CopyButton } from './code-block';\nimport type { AnswerOutcome, ChatArtifactsConfig, ChatCards, CommandMentions } from './config';\nimport { feedbackContent } from './feedback-content';\nimport { SentFeedback } from './sent-feedback';\nimport { ActivityBlock } from './activity';\nimport type { ActivityState } from './activity';\nimport { Shimmer } from './shimmer';\nimport { ToolCard } from './tool';\nimport { thumbnail } from './composer';\nimport { ImageViewer } from './image-viewer';\nimport { displayName, fileKind, isUploadPath, isVoicePath, pieces, visibleParts } from './config';\nimport type { AudioUrlRead, FileKind, Piece, PreviewRead } from './config';\nimport { voiceClock } from './config';\nimport { Spinner } from '@/components/ui/spinner';\nimport { isFileBlock, segments, targetContent } from './rows';\nimport type { DeriveOptions, Row } from './rows';\nimport { Button } from '@/components/ui/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { useChatText } from './labels';\nimport { cn } from '../utils/utils';\nimport type { ReplyRef } from './config';\nimport { Message, MessageContent, MessageFooter } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle, AttachmentTrigger } from '@/components/ui/attachment';\nimport { Badge } from '@/components/ui/badge';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\n\n/** One assistant text part, as `renderText` receives it. `streaming` is true while the model still writes it. */\nexport interface ChatTextPart {\n readonly text: string;\n readonly streaming: boolean;\n /** Draws an `@path` mention (when mentions are on). */\n readonly renderMention?: ((path: string, text: string) => ReactNode) | undefined;\n /** The `/` commands a settled reply may turn into buttons. */\n readonly commandMentions?: CommandMentions | undefined;\n readonly onCopy?: ((text: string) => Promise<void>) | undefined;\n}\n/** The default text renderer: the reply as plain text. `markdownText` of `pi-chat-markdown` draws it as Markdown. */\nconst plainText = ({ text, streaming }: ChatTextPart): ReactNode =>\n <div data-testid=\"message-text\" data-streaming={streaming ? 'true' : undefined} className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport type { ChatCards } from './config';\n\nexport interface RowContext {\n readonly developer: boolean;\n /** Draws an assistant text part. Default: plain text (`markdownText` of `pi-chat-markdown` draws Markdown). */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n readonly cards?: ChatCards | undefined;\n /** `presentation=\"natural\"`: no \"working\" shimmer in a reply (the chat shows one status instead). */\n readonly natural?: boolean | undefined;\n readonly groupTool: DeriveOptions['groupTool'];\n readonly commandMentions: CommandMentions | undefined;\n /** Host openers keep their single image argument; the workspace's default also accepts the message gallery. */\n readonly onOpenImage: ((image: ImageContent, gallery?: readonly ImageContent[]) => void) | undefined;\n readonly onCopy: ((text: string) => Promise<void>) | undefined;\n /** Which tokens of a sent message get a styled treatment (`@path` mentions, a leading `/skill`). */\n readonly pieces: { readonly mentions: boolean; readonly uploads?: boolean; readonly skills: readonly string[]; readonly openMention?: ((path: string) => void) | undefined; readonly renderMention?: ((path: string) => ReactNode | undefined) | undefined;\n readonly preview?: PreviewRead | undefined; readonly audioUrl?: AudioUrlRead | undefined } | undefined;\n readonly fileChanges?: boolean | undefined;\n /** Opens a file card through the host's workspace opener. */\n readonly openFile?: ((path: string) => void) | undefined;\n readonly answer: ((questionId: string, answer: string) => Promise<AnswerOutcome>) | undefined;\n readonly artifacts: { readonly open: ChatArtifactsConfig['open']; readonly isOpen?: ChatArtifactsConfig['isOpen'] | undefined; readonly kinds?: ChatArtifactsConfig['kinds']; readonly Card: NonNullable<ChatArtifactsConfig['Card']> } | undefined;\n /** Replaces the default copy button under a settled reply (the ambient window adds feedback and the time); `host` holds the host's message actions. */\n readonly replyActions?: ((reply: { readonly key: string; readonly text: string; readonly timestamp?: number | undefined }, host: ReactNode) => ReactNode) | undefined;\n /** The host's actions for a settled reply (`messageActions`). */\n readonly messageActions?: ((reply: ReplyRef) => readonly BlockAction[]) | undefined;\n readonly historicalMessageActions?: boolean | undefined;\n /** Host-owned per-answer unread indicator; rendering never marks an answer seen. */\n readonly unread?: ((reply: ReplyRef) => ReactNode) | undefined;\n readonly replyError?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** A line under a finished reply (not while it streams, runs a tool or waits to retry), after its actions (a host shows which model answered and how long it took). */\n readonly replyFooter?: ((reply: ReplyRef) => ReactNode) | undefined;\n /** Fork the conversation after a settled reply (its last native entry): adds a Fork button next to Copy. */\n readonly onFork?: ((entryId: string) => void) | undefined;\n}\n\nconst replyRef = (row: Extract<Row, { type: 'assistant' }>): ReplyRef => ({ key: row.key, text: row.text, entryId: row.entryId, timestamp: row.timestamp, model: row.model, durationMs: row.durationMs });\n\nfunction ImageThumbnails({ images, onOpenImage }: { readonly images: readonly ImageContent[]; readonly onOpenImage: RowContext['onOpenImage'] }) {\n const { labels } = useChatText();\n if (!images.length) return null;\n return <div data-testid=\"message-images\" data-slot=\"message-images\" className={cn('grid max-w-full gap-2', images.length === 1 ? 'w-60' : 'w-120 grid-cols-2')}>\n {images.map((image, at) => <div key={at} className=\"relative min-h-12 min-w-12 self-start\">\n <img src={thumbnail(image.mimeType, image.data)} alt={labels.imageAttachment} className=\"block h-auto w-full rounded-(--radius-xl)\" />\n <Button variant=\"link\" className=\"absolute inset-0 h-auto min-h-12 min-w-12 w-full\" data-testid=\"message-image\" aria-label={images.length > 1 ? `${labels.openImage}, ${labels.imagePosition(at + 1, images.length)}` : labels.openImage} onClick={() => onOpenImage?.(image, images)} />\n </div>)}\n </div>;\n}\n\n/** Inline sent mention; workspace openers and host renderers keep their existing authority. */\nfunction UserMention({ path, text, open, render }: { readonly path: string; readonly text: string; readonly open: ((path: string) => void) | undefined; readonly render: ((path: string) => ReactNode | undefined) | undefined }) {\n const custom = render?.(path);\n if (custom !== undefined) return <>{custom}</>;\n return open\n ? <span role=\"link\" tabIndex={0} className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path} aria-label={`Open ${path}`} onClick={() => open(path)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); open(path); } }}>{text}</span>\n : <span className=\"font-medium text-primary\" data-testid=\"message-mention\" data-path={path}>{text}</span>;\n}\n\n/** The icon a file card shows for its kind. */\nfunction FileGlyph({ kind }: { readonly kind: FileKind }) {\n switch (kind) {\n case 'image': return <ImageIcon aria-hidden=\"true\" />;\n case 'sheet': return <FileSpreadsheetIcon aria-hidden=\"true\" />;\n case 'audio': return <FileAudioIcon aria-hidden=\"true\" />;\n case 'slides': return <PresentationIcon aria-hidden=\"true\" />;\n case 'document': return <FileTextIcon aria-hidden=\"true\" />;\n default: return <FileIcon aria-hidden=\"true\" />;\n }\n}\n\n/** A file a person sent (an `uploads/` mention): its icon or thumbnail, name and type. A button when it can be opened. */\nfunction FileCard({ path, open, preview, change }: { readonly path: string; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined; readonly change?: 'created' | 'edited' }) {\n const { labels } = useChatText();\n const kind = fileKind(path), name = change ? path.split('/').pop() || path : displayName(path);\n const [src, setSrc] = useState<string | undefined>();\n // A thumbnail belongs to the provider that read it: a new provider, or a read that is refused or unavailable, shows the icon.\n useEffect(() => {\n setSrc(undefined);\n if (kind !== 'image' || !preview) return;\n const abort = new AbortController();\n preview(path, abort.signal).then(\n read => { if (!abort.signal.aborted) setSrc(read ? thumbnail(read.mimeType, read.data) : undefined); },\n () => { if (!abort.signal.aborted) setSrc(undefined); });\n return () => abort.abort();\n }, [kind, path, preview]);\n return <Attachment className=\"max-w-64\" {...(!open ? { 'data-testid': 'message-mention', 'data-path': path } : {})}>\n <AttachmentMedia variant={src ? 'image' : 'icon'}>{src ? <img src={src} alt=\"\" /> : <FileGlyph kind={kind} />}</AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>{name}</AttachmentTitle>\n <AttachmentDescription>{change ? labels.fileChange[change] : labels.fileKind[kind]}</AttachmentDescription>\n </AttachmentContent>\n {open && <AttachmentTrigger data-testid=\"message-mention\" data-path={path} aria-label={labels.openFile(name)} onClick={() => open(path)} />}\n </Attachment>;\n}\n\n/** The file cards of a message, above its text bubble. */\nfunction FileCards({ paths, open, preview }: { readonly paths: readonly string[]; readonly open: ((path: string) => void) | undefined; readonly preview: PreviewRead | undefined }) {\n return <ul data-testid=\"message-files\" className=\"m-0 flex max-w-[85%] list-none flex-wrap justify-end gap-2 p-0\">\n {paths.map(path => <li key={path} data-testid=\"message-file\"><FileCard path={path} open={open} preview={preview} /></li>)}\n </ul>;\n}\n\n/** Retained audio is read only on Play, through the same authorized workspace preview as file cards. */\nexport function VoiceBubble({ path, transcript, preview, audioUrl, align = 'end' }: { readonly path: string; readonly transcript: ReactNode; readonly preview: PreviewRead | undefined; readonly audioUrl: AudioUrlRead | undefined; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const audio = useRef<HTMLAudioElement>(null), work = useRef<AbortController | null>(null), loadingRequest = useRef(false);\n const [playing, setPlaying] = useState(false), [loading, setLoading] = useState(false), [failed, setFailed] = useState(false), [duration, setDuration] = useState<number>();\n const source = useRef<string | undefined>(undefined), loader = useRef<() => Promise<boolean>>(async () => false);\n useEffect(() => {\n const element = audio.current;\n let objectUrl: string | undefined;\n source.current = undefined; loadingRequest.current = false; setPlaying(false); setLoading(false); setFailed(false); setDuration(undefined);\n const release = () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = undefined; } };\n const abort = new AbortController(); work.current = abort;\n const load = async () => {\n release();\n const signed = await audioUrl?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (signed) {\n const url = new URL(signed, window.location.href);\n if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Unsupported audio URL');\n source.current = url.href;\n } else {\n const read = await preview?.(path, abort.signal);\n if (abort.signal.aborted) return;\n if (!read || !/^audio\\/(?:webm|ogg|mp4|m4a|x-m4a|mpeg|mp3|wav|x-wav)(?:;|$)/i.test(read.mimeType)) throw new Error('Audio unavailable');\n const bytes = Uint8Array.from(atob(read.data), character => character.charCodeAt(0));\n // MediaRecorder WebM often has no finite metadata duration; decode only these already-requested bytes.\n try {\n if (typeof OfflineAudioContext === 'function') void new OfflineAudioContext(1, 1, 48_000).decodeAudioData(bytes.slice().buffer).then(buffer => {\n if (!abort.signal.aborted && Number.isFinite(buffer.duration)) setDuration(Math.round(buffer.duration));\n }).catch(() => {});\n } catch { /* Optional duration decoding must not prevent native playback. */ }\n objectUrl = URL.createObjectURL(new Blob([bytes], { type: read.mimeType })); source.current = objectUrl;\n }\n if (element && source.current) element.src = source.current;\n };\n // A loader belongs to this path/provider epoch, not to a later row with the same DOM node.\n loader.current = async () => { await load(); return !abort.signal.aborted; };\n return () => { abort.abort(); if (work.current === abort) work.current = null; element?.pause(); element?.removeAttribute('src'); element?.load(); source.current = undefined; release(); };\n }, [path, preview, audioUrl]);\n const toggle = async () => {\n const element = audio.current, abort = work.current;\n if (!element || !abort || loadingRequest.current) return;\n if (!element.paused) { element.pause(); return; }\n loadingRequest.current = true; setFailed(false); setLoading(true);\n try {\n if (!source.current && !await loader.current()) return;\n if (!abort.signal.aborted) await element.play();\n } catch { if (!abort.signal.aborted) setFailed(true); }\n finally { if (work.current === abort && !abort.signal.aborted) { loadingRequest.current = false; setLoading(false); } }\n };\n return <Bubble variant=\"muted\" align={align} data-testid=\"voice-bubble\"><BubbleContent>\n <div className=\"flex items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-play\" aria-label={playing ? labels.voicePause : labels.voicePlay} disabled={loading || (!preview && !audioUrl)} onClick={() => { void toggle(); }}>\n {loading ? <Spinner data-icon=\"inline-start\" aria-hidden=\"true\" /> : playing ? <PauseIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <PlayIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n <span data-testid=\"voice-duration\">{duration === undefined ? '--:--' : voiceClock(duration)}</span>\n <audio ref={audio} preload=\"none\" onPlay={() => { setPlaying(true); setFailed(false); }} onPause={() => setPlaying(false)} onEnded={() => {\n setPlaying(false); const element = audio.current;\n if (element && !Number.isFinite(element.duration) && Number.isFinite(element.currentTime)) setDuration(Math.round(element.currentTime));\n }} onError={() => { setFailed(true); setPlaying(false); source.current = undefined; }} onDurationChange={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} onLoadedMetadata={() => {\n const element = audio.current; if (element && Number.isFinite(element.duration)) setDuration(Math.round(element.duration));\n }} />\n </div>\n {failed && <p role=\"alert\">{labels.voicePlayFailed}</p>}\n {transcript && <div data-testid=\"user-text\" className=\"whitespace-pre-wrap\">{transcript}</div>}\n </BubbleContent></Bubble>;\n}\n\n/**\n * A text part of a sent message. Its `uploads/` mentions become file cards above the bubble and leave the text; the message content keeps\n * them (only what is drawn changes).\n */\n/** `variant` picks the bubble: `muted` (the chat's user bubble) or `default` (the theme's primary, used for my own messages in a shared chat). */\n/** `decoration` sits inside the Bubble before its content (a tail); `header` and `footer` inside the content, around the text (a quote, the time). */\nexport function MessageText({ text, context, align = 'end', variant = 'muted', decoration, header, footer }: { readonly text: string; readonly context: Pick<RowContext, 'pieces' | 'openFile'>; readonly align?: 'start' | 'end'; readonly variant?: 'muted' | 'default'; readonly decoration?: ReactNode; readonly header?: ReactNode; readonly footer?: ReactNode }) {\n const framed = decoration !== undefined || header !== undefined || footer !== undefined;\n const wrap = (body: ReactNode) => <Bubble variant={variant} align={align}>{decoration}<BubbleContent data-testid=\"user-text\" className={framed ? 'relative' : undefined}>{header}{body}{footer}</BubbleContent></Bubble>;\n const target = targetContent(text);\n if (target) return <>\n <Badge variant=\"secondary\" data-testid=\"message-target\" title={JSON.stringify(target.target)} className=\"max-w-full truncate\">{target.label}</Badge>\n <MessageText text={target.speech} context={context} align={align} variant={variant} decoration={decoration} header={header} footer={footer} />\n </>;\n const feedback = feedbackContent(text);\n if (feedback) return <>\n {feedback.before && <MessageText text={feedback.before} context={context} align={align} variant={variant} />}\n <SentFeedback report={feedback.report} align={align} />\n {feedback.after && <MessageText text={feedback.after} context={context} align={align} variant={variant} />}\n </>;\n const open = context.pieces?.openMention;\n const render = context.pieces?.renderMention;\n const styledUploads = Boolean(context.pieces?.mentions || context.pieces?.uploads);\n const all = pieces(text, { mentions: context.pieces?.mentions ?? false, uploads: true, skills: context.pieces?.skills ?? [] }).map((piece): Piece =>\n !styledUploads && piece.kind === 'mention' && isUploadPath(piece.value!) && !isVoicePath(piece.value!) ? { text: piece.text } : piece);\n const isFile = (piece: Piece) => piece.kind === 'mention' && isUploadPath(piece.value!);\n const files = all.filter(isFile).map(piece => piece.value!);\n const voice = files.find(isVoicePath), cards = files.filter(path => path !== voice);\n if (!context.pieces && !voice) return wrap(<span className=\"whitespace-pre-wrap\">{text}</span>);\n const shown = files.length ? visibleParts(all, isFile) : all;\n const empty = shown.every(piece => piece.kind === undefined && piece.text === '');\n const content = shown.map((piece, at) => piece.kind === 'mention'\n ? <UserMention key={at} path={piece.value!} text={piece.text} open={open} render={render} />\n : piece.kind === 'skill' ? <Badge key={at} variant=\"secondary\" data-testid=\"message-skill\" data-skill={piece.value}>{piece.text}</Badge>\n : piece.text);\n return <>\n {cards.length > 0 && <FileCards paths={cards} open={context.openFile ?? open} preview={context.pieces?.preview} />}\n {voice ? <VoiceBubble path={voice} transcript={empty ? null : content} preview={context.pieces?.preview} audioUrl={context.pieces?.audioUrl} align={align} />\n : !(files.length > 0 && empty) && wrap(<span className=\"whitespace-pre-wrap\">{content}</span>)}\n </>;\n}\n\nfunction UserBubble({ message, images, context, align = 'end' }: { readonly message: UserMessage; readonly images: readonly ImageContent[]; readonly context: RowContext; readonly align?: 'start' | 'end' }) {\n const parts = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;\n return <Message align={align}><MessageContent>\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {parts.map((part, index) => part.type === 'text' ? (isFileBlock(part.text) ? null : <MessageText key={index} text={part.text} context={context} align={align} />)\n : null)}\n </MessageContent></Message>;\n}\n\nfunction Notice({ tone, icon, children, testid }: { readonly tone: 'error' | 'muted'; readonly icon: ReactNode; readonly children: ReactNode; readonly testid: string }) {\n return <Alert variant={tone === 'error' ? 'destructive' : 'default'} role={tone === 'error' ? 'alert' : 'status'} data-testid={testid}>\n {icon}<AlertDescription>{children}</AlertDescription>\n </Alert>;\n}\n\n/**\n * A question or an approval waits for the person but this chat has no card for it (no `pi-chat-question` or `pi-chat-approval`): say so,\n * so the run is never silently blocked. It offers no answer UI.\n */\nfunction AnswerWaiting({ kind }: { readonly kind: 'question' | 'approval' }) {\n const { labels } = useChatText();\n return <Alert role=\"status\" data-testid=\"answer-waiting\" data-kind={kind}>\n <AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" /><AlertDescription>{labels.waitingForAnswer}</AlertDescription>\n </Alert>;\n}\n\nfunction resultText(message: ToolResultMessage): string { return message.content.map(part => part.type === 'text' ? part.text : '').join('\\n'); }\n\nfunction messageImages(row: Row): readonly ImageContent[] {\n const images = (content: ToolResultMessage['content']) => content.flatMap(part => part.type === 'image' && thumbnail(part.mimeType, part.data) ? [part] : []);\n if (row.type === 'user') return typeof row.message.content === 'string' ? [] : images(row.message.content);\n if (row.type === 'orphan-result') return images(row.message.content);\n return row.type === 'assistant' ? row.images?.filter(image => thumbnail(image.mimeType, image.data)) ?? row.parts.flatMap(part => part.kind === 'tool' && part.entry.result ? images(part.entry.result.content) : []) : [];\n}\n\n/**\n * A run of transcript rows as one scroller item (`useRowChunks`). Memoized, like each row: a row renders again only when its own row\n * or the chat's row context changes, never on a scroll or another row's update. Pass stable callbacks (`messageActions`, `unread`…)\n * so the context stays the same.\n */\nexport const TranscriptChunk = memo(function TranscriptChunk({ rows, context }: { readonly rows: readonly Row[]; readonly context: RowContext }) {\n return <MessageScrollerItem messageId={rows[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {rows.map(row => <TranscriptRow key={row.key} row={row} context={context} />)}\n </MessageScrollerItem>;\n});\nconst TranscriptRow = memo(function TranscriptRow({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n return <div data-row-key={row.key} className=\"min-w-0 shrink-0\"><RowView row={row} context={context} /></div>;\n});\n\nexport function RowView({ row, context }: { readonly row: Row; readonly context: RowContext }) {\n const images = messageImages(row);\n const [selection, setSelection] = useState<{ readonly index: number; readonly images: readonly ImageContent[]; readonly key: string; readonly opener: RowContext['onOpenImage'] }>();\n const current = selection && selection.key === row.key && selection.opener === context.onOpenImage && selection.images.length === images.length\n && selection.images.every((image, at) => image.mimeType === images[at]?.mimeType && image.data === images[at]?.data) ? selection : undefined;\n useEffect(() => { if (selection && !current) setSelection(undefined); }, [selection, current]);\n const openImage: NonNullable<RowContext['onOpenImage']> = image => {\n if (!thumbnail(image.mimeType, image.data)) return;\n if (context.onOpenImage) { context.onOpenImage(image, images); return; }\n const index = images.indexOf(image);\n if (index >= 0) setSelection({ index, images, key: row.key, opener: context.onOpenImage });\n };\n return <><RowBody row={row} images={images} context={{ ...context, onOpenImage: openImage }} />\n {current && <ImageViewer images={images} index={current.index} onIndexChange={index => setSelection({ ...current, index })} onClose={() => setSelection(undefined)} />}\n </>;\n}\n\nfunction RowBody({ row, images, context: liveContext }: { readonly row: Row; readonly images: readonly ImageContent[]; readonly context: RowContext }) {\n const context: RowContext = row.historical ? { ...liveContext, answer: undefined, onFork: undefined, messageActions: liveContext.historicalMessageActions ? liveContext.messageActions : undefined, replyActions: undefined, commandMentions: undefined } : liveContext;\n const { labels, icons } = useChatText();\n const renderMention = useMemo(() => context.pieces?.mentions ? (path: string, text: string) => <UserMention path={path} text={text} open={context.pieces?.openMention} render={context.pieces?.renderMention} /> : undefined, [context.pieces]);\n switch (row.type) {\n case 'user': return <article data-row-id={row.key} data-role=\"user\" aria-live={row.historical ? 'off' : undefined}><UserBubble message={row.message} images={images} context={context} /></article>;\n case 'card': return <article data-row-id={row.key} data-role=\"event\">{row.card.content}</article>;\n case 'event': return <article data-row-id={row.key} data-role=\"event\" className=\"text-center text-xs text-muted-foreground\">{labels.event(row.label)}</article>;\n case 'system': return <article data-row-id={row.key} data-role=\"system\"><Collapsible>\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{labels.systemContext}</CollapsibleTrigger>\n <CollapsibleContent><pre className=\"whitespace-pre-wrap\">{row.text}</pre></CollapsibleContent>\n </Collapsible></article>;\n case 'orphan-result': return <article data-row-id={row.key} data-role=\"toolResult\"><Notice tone=\"error\" testid=\"tool-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>\n <strong className=\"font-semibold\">{labels.toolFailed(row.message.toolName)}</strong><span className=\"mt-1 block font-mono text-xs whitespace-pre-wrap\">{resultText(row.message)}</span></Notice><ImageThumbnails images={images} onOpenImage={context.onOpenImage} /></article>;\n case 'assistant': {\n const settled = !row.streaming;\n const nativeActions = settled;\n const files = new Map<string, 'created' | 'edited'>();\n if (context.fileChanges) for (const part of row.parts) {\n if (part.kind !== 'tool' || part.entry.status !== 'completed') continue;\n const { call, result } = part.entry;\n const path = call.arguments['path'];\n if (!result || result.isError || result.toolName !== call.name || (call.name !== 'write' && call.name !== 'edit') || typeof path !== 'string' || !path.trim()) continue;\n const normalized = path.replace(/^(\\.\\/)+/, '');\n if (normalized) files.set(normalized, files.get(normalized) === 'created' || call.name === 'write' ? 'created' : 'edited');\n }\n const chunks = segments(row.parts, context.groupTool);\n const { question: Question, approval: Approval } = context.cards ?? {};\n const active = row.active === true || row.streaming;\n const lastText = [...row.parts].reverse().find(part => part.kind === 'text');\n const host = nativeActions && row.text && context.messageActions\n ? <BlockActions iconOnly actions={context.messageActions(replyRef(row))} testId=\"reply-action\" menuLabel={labels.moreActions} />\n : null;\n const unread = nativeActions && row.text ? context.unread?.(replyRef(row)) : null;\n const footer = nativeActions && !row.active && row.text ? context.replyFooter?.(replyRef(row)) : null;\n return <article data-row-id={row.key} data-role=\"assistant\" aria-live={row.historical ? 'off' : undefined} data-streaming={row.streaming ? 'true' : undefined} className=\"group/message\">\n <Message><MessageContent>\n <div className=\"flex flex-col gap-3\">\n {chunks.map((chunk, at) => {\n if (chunk.kind === 'activity') {\n const failed = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'failed');\n const unfinished = chunk.steps.some(step => step.kind === 'tool' && step.entry.status === 'unfinished');\n const state: ActivityState = failed || row.stopReason === 'error' ? 'failed' : row.stopReason === 'aborted' || (!active && unfinished) ? 'stopped' : active && at === chunks.length - 1 ? 'running' : 'done';\n return <ActivityBlock key={chunk.key} steps={chunk.steps} state={state} streaming={row.streaming} developer={context.developer} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n }\n const part = chunk.part;\n if (part.kind === 'card') return <div key={part.key}>{part.content}</div>;\n if (part.kind === 'text') return <Bubble key={part.key} variant=\"ghost\"><BubbleContent>{(context.renderText ?? plainText)({ text: part.text, renderMention, streaming: row.streaming && part === lastText && row.parts[row.parts.length - 1] === part,\n ...(settled && context.commandMentions ? { commandMentions: context.commandMentions } : {}), ...(context.onCopy ? { onCopy: context.onCopy } : {}) })}</BubbleContent></Bubble>;\n if (part.kind === 'thinking') return null; // reasoning is a step of an activity block\n if (part.kind === 'approval') return Approval ? <Approval key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} summary={part.summary} decision={part.decision} live={part.live} />\n : !part.live && !part.result && !part.decision ? <AnswerWaiting key={part.key} kind=\"approval\" /> : null;\n if (part.kind === 'subagent') return <Fragment key={part.key}>{part.content}</Fragment>;\n if (part.kind === 'question') return Question ? <Question key={part.key} call={part.call} result={part.result} questionId={part.questionId} answer={context.answer} live={part.live} />\n : !part.live && !part.result ? <AnswerWaiting key={part.key} kind=\"question\" /> : null;\n if (part.kind === 'artifact') {\n const { artifact } = part, artifacts = context.artifacts;\n if (!artifacts || (artifact.path !== undefined && files.has(artifact.path.replace(/^(\\.\\/)+/, '')))) return null;\n const descriptor = artifact.descriptor as never;\n return <artifacts.Card key={part.key} artifact={artifact} open={artifact.state === 'ready' && (artifacts.isOpen?.(descriptor) ?? false)}\n onOpen={artifact.state === 'ready' && artifacts.open ? () => artifacts.open(descriptor) : undefined} kinds={artifacts.kinds} />;\n }\n return part.entry.custom ? <div key={part.key}>{part.entry.custom}</div> : <ToolCard key={part.key} entry={part.entry} {...(context.onOpenImage ? { onOpenImage: context.onOpenImage } : {})} />;\n })}\n <ImageThumbnails images={images} onOpenImage={context.onOpenImage} />\n {files.size > 0 && <ul data-testid=\"reply-files\" className=\"m-0 flex list-none flex-wrap gap-2 p-0\">\n {[...files].map(([path, change]) => <li key={path} data-testid=\"message-file\"><FileCard path={path} change={change} open={context.openFile} preview={context.pieces?.preview} /></li>)}\n </ul>}\n {row.streaming && row.parts.length === 0 && !context.natural && <p data-testid=\"working\" className=\"m-0 text-sm\"><Shimmer>{labels.thinking}</Shimmer></p>}\n </div>\n {row.stopReason === 'error' && <Notice tone=\"error\" testid=\"response-error\" icon={<AlertCircleIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{context.replyError?.(replyRef(row)) ?? row.errorMessage ?? labels.responseFailed}</Notice>}\n {row.stopReason === 'aborted' && <Notice tone=\"muted\" testid=\"interrupted\" icon={<CircleSlashIcon className=\"mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />}>{labels.interrupted}</Notice>}\n {unread != null && unread !== false && <div data-testid=\"reply-unread\" className=\"flex flex-col items-start\">{unread}</div>}\n {nativeActions && row.text && context.replyActions?.({ key: row.key, text: row.text, timestamp: row.timestamp }, host)}\n {nativeActions && row.text && !context.replyActions && <div className=\"mt-1 -ml-2 flex opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100 max-sm:opacity-100 motion-reduce:transition-none\">\n <CopyButton iconOnly text={row.text} label={labels.copyResponse} {...(context.onCopy ? { onCopy: context.onCopy } : {})} />\n {context.onFork && row.entryId && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.fork} title={labels.forkHint} data-testid=\"fork-reply\"\n onClick={() => context.onFork!(row.entryId!)}><icons.fork data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}</div>}\n {footer != null && footer !== false && <MessageFooter>{footer}</MessageFooter>}\n </MessageContent></Message>\n </article>;\n }\n }\n}\n",
117
117
  "type": "registry:component",
118
118
  "target": "components/pi-chat/message.tsx"
119
119
  },
@@ -125,7 +125,7 @@
125
125
  },
126
126
  {
127
127
  "path": "registry/pi-chat/pi-chat.tsx",
128
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\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 { AttachmentsConfig, ConversationsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { RowView } from './message';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from './notice';\nimport { Shimmer } from './shimmer';\nimport { useChatSession, 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, 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 { 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 { 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 }) => 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 /** 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 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 /** 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\nconst PAGE = 60, MORE = 40;\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); 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, 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, 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', conversationTitle, onRenameTitle, feedback, 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 voice = (voiceMessage?.useRecorder ?? noVoice)({ 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); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, 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, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows, 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 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, retry } = derived;\n const showPreparing = preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry, compacting: derived.compacting, stop: state.stop });\n const transcript = useTranscript(transcriptRows, displayLimit ?? PAGE, MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const visible = transcript.visible(transcriptRows);\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 useEffect(() => {\n const viewport = globalThis.visualViewport;\n if (!keyboardInset || !viewport) return;\n const element = page.current;\n if (!element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.offsetTop)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure(); viewport.addEventListener('resize', measure); viewport.addEventListener('scroll', measure);\n return () => { observer?.disconnect(); viewport.removeEventListener('resize', measure); viewport.removeEventListener('scroll', measure); };\n }, []);\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-[641px]:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'min-[641px]:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'min-[641px]:absolute min-[641px]:inset-x-0 min-[641px]:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 sm:gap-3 sm: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-[641px]: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 <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? state.connection.kind === 'error' && renderConnectionError ? renderConnectionError(state.connection.error) : labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></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' && 'min-[641px]:min-h-60 min-[641px]:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows }) : <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <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 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visible.map(item => <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice={item.kind === 'voice' ? '' : undefined} className=\"[content-visibility:visible]\">{item.kind === 'native' ? <RowView row={item.row} context={rowContext} /> : <VoiceBubbleView pending={item.voice} />}</MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <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))] sm: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 {session.natural ? showPreparing && <PreparingStatus>{derived.stageStatus}</PreparingStatus> : working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {!session.natural && derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\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 <Composer {...composer} layout={composerLayout} placeholder={labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder} barStart={barStart} barNote={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 {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
128
+ "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\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 { AttachmentsConfig, ConversationsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoiceRecorderHook } from './config';\nimport { displayName } from './config';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from './notice';\nimport { Shimmer } from './shimmer';\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 { 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 }) => 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 /** 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 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 /** 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\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller); 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, 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', conversationTitle, onRenameTitle, feedback, 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 voice = (voiceMessage?.useRecorder ?? noVoice)({ 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); fork.current = conversations?.fork;\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : undefined, [canFork]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, 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, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows, 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 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, retry } = derived;\n const showPreparing = preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry, compacting: derived.compacting, stop: state.stop });\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;\n 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 useEffect(() => {\n const viewport = globalThis.visualViewport;\n if (!keyboardInset || !viewport) return;\n const element = page.current;\n if (!element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.offsetTop)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure(); viewport.addEventListener('resize', measure); viewport.addEventListener('scroll', measure);\n return () => { observer?.disconnect(); viewport.removeEventListener('resize', measure); viewport.removeEventListener('scroll', measure); };\n }, []);\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-[641px]:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'min-[641px]:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'min-[641px]:absolute min-[641px]:inset-x-0 min-[641px]:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 sm:gap-3 sm: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-[641px]: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 <Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? state.connection.kind === 'error' && renderConnectionError ? renderConnectionError(state.connection.error) : labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}\n </Notice></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' && 'min-[641px]:min-h-60 min-[641px]:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging }) : <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <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 sm: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 && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <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))] sm: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 {session.natural ? showPreparing && <PreparingStatus>{derived.stageStatus}</PreparingStatus> : working && <p role=\"status\" data-testid=\"agent-status\" className=\"m-0 flex items-center gap-2 px-1 text-xs text-muted-foreground\">\n <Shimmer active={!waitingForAnswer}>{waitingForAnswer ? labels.waitingForAnswer : retry ? labels.retryPending : labels.agentWorking}</Shimmer></p>}\n {!session.natural && derived.compacting && <p role=\"status\" className=\"m-0 px-1 text-xs text-muted-foreground\">{labels.compacting}</p>}\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 <Composer {...composer} layout={composerLayout} placeholder={labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder} barStart={barStart} barNote={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 {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
129
129
  "type": "registry:component",
130
130
  "target": "components/pi-chat/pi-chat.tsx"
131
131
  },
@@ -149,7 +149,7 @@
149
149
  },
150
150
  {
151
151
  "path": "registry/pi-chat/session.tsx",
152
- "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, 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, PendingUpload } from './composer';\nimport type { ConversationFile } from './conversation-files';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { displayName, looksLikeMarkdown, mentionToken, pieces } from './config';\nimport type { CommandMentions } from './config';\nimport type { ChatCards, ChatTextPart, RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome, ChatArtifactsConfig } from './config';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode, Row } 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 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 /** 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\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<ChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\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}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool: catchAll, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const natural = presentation === 'natural';\n // A tool's own renderer comes first; `renderTool` stays the catch-all.\n const renderTool = useMemo(() => toolRenderers ? (call: ToolCall, result: ToolResultMessage | undefined) => (Object.hasOwn(toolRenderers, call.name) ? toolRenderers[call.name]?.(call, result) : undefined) ?? catchAll?.(call, result) : catchAll, [toolRenderers, catchAll]);\n const developer = mode === 'developer' && !natural;\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const offered = artifacts?.recognise && artifacts.Card ? artifacts : undefined;\n const artifactDetect = useMemo(() => offered?.recognise ? { recognise: offered.recognise, detect: offered.detect } : undefined, [offered?.recognise, offered?.detect]);\n const subagent = cards?.subagent;\n const derived = useMemo(() => derive(state.view, { mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifacts: artifactDetect, subagent }), [state.view, mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifactDetect, subagent]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { run } = derived;\n const earlier = useMemo<readonly Row[]>(() => {\n if (!state.view || !('entries' in state.history)) return [];\n const activeIds = new Set(state.view.entries.map(entry => entry.id));\n const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));\n return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows\n .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));\n }, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);\n const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);\n const files = useMemo(() => {\n const found = new Map<string, ConversationFile>();\n const open = onOpenFile ?? mentions?.open;\n const add = (path: string) => {\n const normalized = path.replace(/^\\.\\//, '');\n const id = `file:${normalized}`;\n if (!found.has(id)) found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });\n };\n for (const path of picked) add(path);\n for (const item of state.draft.attachments) found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });\n for (const row of derived.rows) {\n if (row.type === 'user') {\n const content = typeof row.message.content === 'string' ? [{ type: 'text' as const, text: row.message.content }] : row.message.content;\n for (const [index, part] of content.entries()) {\n if (part.type === 'text' && !isFileBlock(part.text)) for (const piece of pieces(part.text, { mentions: true, skills: [] })) { if (piece.kind === 'mention' && piece.value) add(piece.value); }\n if (part.type === 'image') { const id = `image:${row.key}:${index}`; found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined }); }\n }\n }\n if (row.type !== 'assistant') continue;\n for (const part of row.parts) {\n if (part.kind === 'artifact' && part.artifact.state === 'ready' && part.artifact.path !== undefined) {\n const descriptor = part.artifact.descriptor as never, id = `file:${part.artifact.path.replace(/^\\.\\//, '')}`;\n found.set(id, { id, name: displayName(part.artifact.path), open: artifacts?.open ? () => artifacts.open(descriptor) : undefined });\n }\n if (!natural && part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {\n const path = part.entry.call.arguments['path'];\n if (typeof path === 'string') add(path);\n }\n }\n }\n return [...found.values()];\n }, [rows, picked, state.draft.attachments, artifacts, onOpenFile, mentions?.open, onOpenImage, labels.imageAttachment]);\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = derived.rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if (!files.length || !current()) return;\n if (onPickFiles) { onPickFiles(files); return; }\n if (!onFiles && !attachments) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\n };\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, natural, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: 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 ? { 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\n }), [controller, developer, natural, 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 = 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={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <Button variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{changeError}</span></Button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\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 working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n hasFiles: Boolean(composerHasContent),\n uploading: uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(camera ? { camera } : {}), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n onPasteText: attachments ? pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, files, pickers, empty, loading, developer, natural };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\n/** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */\nexport function useTranscript(rows: readonly { readonly key: string }[], page = 60, more = 40, history?: { readonly state: ChatHistoryState; readonly connected: boolean; readonly load: () => Promise<void> }, automatic = true) {\n if (!Number.isSafeInteger(page) || page < 1) throw new TypeError('Chat displayLimit must be a positive integer');\n const [limit, setLimit] = useState(page);\n const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);\n const [loaded, setLoaded] = useState(0);\n const pending = useRef(false);\n const anchor = useRef<{ readonly length: number; readonly limit: number } | undefined>(undefined);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n const stick = { scrollRef: setScroller, scrollToBottom, isAtBottom: !scrollable.end };\n const hidden = Math.max(0, rows.length - limit);\n const reveal = useCallback(() => {\n if (pending.current || anchor.current) return;\n const state = history?.state;\n if (hidden === 0 && (!history?.connected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore)) return;\n anchor.current = { length: rows.length, limit };\n if (hidden > 0) setLimit(value => value + more);\n else if (history) {\n pending.current = true;\n void Promise.resolve().then(history.load).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });\n }\n }, [hidden, history?.state, history?.connected, history?.load, rows.length, limit, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (!scroller || !saved || history?.state.kind === 'loading') return;\n const added = Math.max(0, rows.length - saved.length);\n if (limit < saved.limit + added) { setLimit(saved.limit + added); return; }\n anchor.current = undefined;\n setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));\n }, [rows, limit, scroller, history?.state]);\n useEffect(() => {\n if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error') return;\n const observer = new IntersectionObserver(entries => {\n if (entries.some(entry => entry.isIntersecting) && (scroller.scrollHeight <= scroller.clientHeight || scrollable.start)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => observer.disconnect();\n }, [automatic, scroller, sentinel, reveal, scrollable.start, history?.state]);\n const revealTo = (key: string) => { const index = rows.findIndex(row => row.key === key); if (index >= 0) setLimit(value => Math.max(value, rows.length - index)); };\n return { stick, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };\n}\n",
152
+ "content": "'use client';\n\nimport { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { ChatAttachment, 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, PendingUpload } from './composer';\nimport type { ConversationFile } from './conversation-files';\nimport type { AttachmentsConfig, EffortConfig, MentionsConfig, ModelConfig, ModelRef, PastedText, PasteToFile, SlashConfig } from './config';\nimport { displayName, looksLikeMarkdown, mentionToken, pieces } from './config';\nimport type { CommandMentions } from './config';\nimport type { ChatCards, ChatTextPart, RowContext } from './message';\nimport type { QueueActions, QueuedMessage } from './queue';\nimport type { AnswerOutcome, ChatArtifactsConfig } from './config';\nimport { derive, isFileBlock, object, queuedMessages } from './rows';\nimport type { ChatCard, Mode, Row } 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 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\nexport type FilesHandler = (files: readonly File[], target: Pick<ReturnType<ChatController['getSnapshot']>, 'identity' | 'conversationId'> & { readonly signal: AbortSignal }) => Promise<readonly ChatAttachment[]>;\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}\n\nexport function useChatSession(options: ChatSessionOptions) {\n const { controller, activeController: active, mode, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool: catchAll, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend, rowExtras, feedback, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles } = options;\n const { labels } = useChatText();\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const [error, setError] = useState<string | null>(null);\n const [uploading, setUploading] = useState(0);\n const [picked, setPicked] = useState<readonly string[]>([]);\n const [pending, setPending] = useState<readonly PendingUpload[]>([]);\n const [changing, setChanging] = useState<'model' | 'effort' | null>(null);\n const [changeError, setChangeError] = useState<string | null>(null);\n const [optimistic, setOptimistic] = useState<{ readonly model?: ModelRef; readonly effort?: string }>({});\n const uploadIds = useRef(0);\n const uploads = useRef(new Set<AbortController>()), mounted = useRef(false);\n const textarea = useRef<HTMLTextAreaElement | null>(null);\n const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;\n useEffect(() => {\n mounted.current = true;\n // A controller disposed before mount throws synchronously; the chat then renders its disposed state.\n if (!controller.getSnapshot().disposed) void controller.connect().catch(cause => { if (current()) setError(cause instanceof Error ? cause.message : labels.connectionFailed); });\n return () => { mounted.current = false; for (const upload of uploads.current) upload.abort(); uploads.current.clear(); };\n }, [controller]);\n\n const natural = presentation === 'natural';\n // A tool's own renderer comes first; `renderTool` stays the catch-all.\n const renderTool = useMemo(() => toolRenderers ? (call: ToolCall, result: ToolResultMessage | undefined) => (Object.hasOwn(toolRenderers, call.name) ? toolRenderers[call.name]?.(call, result) : undefined) ?? catchAll?.(call, result) : catchAll, [toolRenderers, catchAll]);\n const developer = mode === 'developer' && !natural;\n // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.\n const offered = artifacts?.recognise && artifacts.Card ? artifacts : undefined;\n const artifactDetect = useMemo(() => offered?.recognise ? { recognise: offered.recognise, detect: offered.detect } : undefined, [offered?.recognise, offered?.detect]);\n const subagent = cards?.subagent;\n const derived = useMemo(() => derive(state.view, { mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifacts: artifactDetect, subagent }), [state.view, mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifactDetect, subagent]);\n const queued = useMemo(() => queuedMessages(state.view), [state.view]);\n const { run } = derived;\n const earlier = useMemo<readonly Row[]>(() => {\n if (!state.view || !('entries' in state.history)) return [];\n const activeIds = new Set(state.view.entries.map(entry => entry.id));\n const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));\n return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows\n .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));\n }, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);\n const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);\n const files = useMemo(() => {\n const found = new Map<string, ConversationFile>();\n const open = onOpenFile ?? mentions?.open;\n const add = (path: string) => {\n const normalized = path.replace(/^\\.\\//, '');\n const id = `file:${normalized}`;\n if (!found.has(id)) found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });\n };\n for (const path of picked) add(path);\n for (const item of state.draft.attachments) found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });\n for (const row of derived.rows) {\n if (row.type === 'user') {\n const content = typeof row.message.content === 'string' ? [{ type: 'text' as const, text: row.message.content }] : row.message.content;\n for (const [index, part] of content.entries()) {\n if (part.type === 'text' && !isFileBlock(part.text)) for (const piece of pieces(part.text, { mentions: true, skills: [] })) { if (piece.kind === 'mention' && piece.value) add(piece.value); }\n if (part.type === 'image') { const id = `image:${row.key}:${index}`; found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined }); }\n }\n }\n if (row.type !== 'assistant') continue;\n for (const part of row.parts) {\n if (part.kind === 'artifact' && part.artifact.state === 'ready' && part.artifact.path !== undefined) {\n const descriptor = part.artifact.descriptor as never, id = `file:${part.artifact.path.replace(/^\\.\\//, '')}`;\n found.set(id, { id, name: displayName(part.artifact.path), open: artifacts?.open ? () => artifacts.open(descriptor) : undefined });\n }\n if (!natural && part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {\n const path = part.entry.call.arguments['path'];\n if (typeof path === 'string') add(path);\n }\n }\n }\n return [...found.values()];\n }, [rows, picked, state.draft.attachments, artifacts, onOpenFile, mentions?.open, onOpenImage, labels.imageAttachment]);\n\n const report = (cause: unknown) => { if (current()) setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };\n const act = (action: () => Promise<unknown>) => {\n setError(null);\n try { void action().catch(report); } catch (cause) { report(cause); }\n };\n // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.\n // Only a submission whose outcome is unknown blocks, until it is checked or retried.\n const unconfirmed = state.send.kind === 'unknown';\n const connected = state.connection.kind === 'connected';\n const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;\n /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */\n const putBack = (text: string, images: readonly ChatAttachment[]) => {\n if (controller.getSnapshot().disposed) return;\n const draft = controller.getSnapshot().draft;\n controller.setText([text, draft.text].filter(value => value.trim()).join('\\n'));\n if (images.length) controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);\n };\n const working = Boolean(run);\n const waitingForAnswer = derived.rows.some(row => row.type === 'assistant' && row.parts.some(part => (part.kind === 'question' || part.kind === 'approval') && !part.live && !part.result));\n const send = () => {\n if (sendBlocked || !current()) return;\n const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;\n if (attach) {\n // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.\n act(async () => {\n // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.\n const typed = controller.getSnapshot().draft;\n controller.setText(''); controller.setAttachments([]);\n let attached: Awaited<ReturnType<typeof attach>>;\n try { attached = await attach(typed.text); }\n catch (cause) { putBack(typed.text, typed.attachments); throw cause; }\n if (attached.kind === 'refused') { putBack(typed.text, typed.attachments); throw new Error(attached.reason); }\n if (!current()) { putBack(typed.text, typed.attachments); return; }\n const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: typed.text, attachments: typed.attachments } });\n if (submission) feedback?.sent();\n });\n afterSend?.();\n textarea.current?.focus();\n return;\n }\n act(() => controller.send('followUp'));\n afterSend?.();\n textarea.current?.focus();\n };\n // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.\n async function takeBack(item: QueuedMessage) {\n if (!actions?.withdraw) throw new Error(labels.queueLocked);\n const outcome = await actions.withdraw(item.id);\n if (outcome === 'already_placed' || outcome === 'settled') throw new Error(labels.tooLate);\n }\n const queuedContent = (item: QueuedMessage) => {\n const parts = Array.isArray(item.content) ? item.content as readonly { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }[] : [];\n const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\\n');\n const images: ChatAttachment[] = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'\n ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image' as const, data: part.data, mimeType: part.mimeType } }] : []);\n return { text, images };\n };\n const queueActions: QueueActions | undefined = actions?.withdraw ? {\n edit: async item => {\n await takeBack(item);\n const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;\n controller.setText(draft.text.trim() ? `${text}\\n${draft.text}` : text);\n if (images.length) controller.setAttachments([...draft.attachments, ...images]);\n textarea.current?.focus();\n },\n steer: async item => {\n if (sendBlocked) throw new Error(labels.waitToSteer);\n await takeBack(item);\n const { text, images } = queuedContent(item);\n // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller\n // puts it back), an unknown one is held for checking.\n try {\n const submission = await controller.send('steer', { text, attachments: images });\n if (!submission && current() && controller.getSnapshot().send.kind === 'blocked') setError(labels.steerRefused);\n } catch (cause) { putBack(text, images); if (current()) setError(cause instanceof Error ? cause.message : labels.steerFailed); }\n },\n } : undefined;\n const addMentions = (paths: readonly string[]) => {\n const text = controller.getSnapshot().draft.text;\n controller.setText(`${text}${text && !/\\s$/.test(text) ? ' ' : ''}${paths.map(mentionToken).join(' ')} `);\n setPicked(value => [...new Set([...value, ...paths])]);\n };\n /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */\n async function upload(files: readonly File[], paste?: { readonly restore: () => void; readonly after: string }) {\n if (!files.length || !current()) return;\n if (onPickFiles) { onPickFiles(files); return; }\n if (!onFiles && !attachments) return;\n const abort = new AbortController(); uploads.current.add(abort); setUploading(value => value + 1);\n const ids = files.map(() => `upload-${++uploadIds.current}`);\n const settle = (failure?: string) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))\n : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));\n const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });\n setPending(items => [...items, ...files.map((file, index): PendingUpload => ({ id: ids[index]!, name: file.name, state: 'uploading',\n ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);\n try {\n let added: readonly ChatAttachment[] = [];\n let paths: string[] = [];\n if (attachments) {\n for (const result of await attachments.upload([...files], abort.signal)) {\n if (result.image) added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];\n else if (result.path) paths = [...paths, result.path];\n }\n } else added = await onFiles!(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });\n if (current() && !abort.signal.aborted) {\n if (added.length) controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);\n if (paths.length) addMentions(paths);\n settle();\n }\n } catch (cause) { if (!abort.signal.aborted) { settle(cause instanceof Error ? cause.message : labels.uploadFailedShort); if (!attachments) report(cause); } }\n finally { uploads.current.delete(abort); if (current()) setUploading(value => value - 1); }\n }\n /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */\n const pasteText = (paste: PastedText) => {\n if (!attachments || !current()) return;\n const at = new Date(), two = (value: number) => String(value).padStart(2, '0');\n const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;\n const markdown = looksLikeMarkdown(paste.text);\n const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });\n // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at\n // the same place and deletes nothing: whatever was typed since stays.\n const draft = controller.getSnapshot().draft.text;\n const start = Math.min(paste.start, draft.length), end = Math.min(Math.max(paste.end, start), draft.length);\n const after = `${draft.slice(0, start)}${draft.slice(end)}`;\n controller.setText(after);\n const restore = () => {\n if (!current()) return;\n const now = controller.getSnapshot().draft.text;\n const at = Math.min(start, now.length);\n controller.setText(`${now.slice(0, at)}${paste.text}${now.slice(at)}`);\n textarea.current?.focus();\n };\n void upload([file], { restore, after });\n };\n const agentDoc = object(state.view?.docs['pi.agent']);\n const currentModel = object(agentDoc?.['model']);\n const viewModel: ModelRef | undefined = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;\n const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;\n // Show the accepted choice until the watch delivers it, so the label does not flicker back.\n useEffect(() => {\n const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;\n if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort)) setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));\n }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);\n useEffect(() => { if (!state.draft.text) setPicked(value => value.length ? [] : value); }, [state.draft.text]);\n async function change(kind: 'model' | 'effort', apply: () => Promise<void>, accepted: () => void, rejected: () => void) {\n if (changing || !current()) return;\n setChanging(kind); setChangeError(null);\n try { await apply(); if (current()) accepted(); }\n catch (cause) { if (current()) { rejected(); setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused); } }\n finally { if (current()) setChanging(null); }\n }\n const activateMention = (command: { readonly name: string; readonly behavior: 'execute' | 'insert' }) => {\n if (!current() || controller.getSnapshot().connection.kind !== 'connected') return;\n const catalog = commandMentions;\n const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);\n if (catalog && selected) act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));\n };\n const rowContext = useMemo<RowContext>(() => ({\n developer, natural, renderText, cards, groupTool, onOpenImage, onCopy, openFile: onOpenFile ?? mentions?.open, fileChanges,\n commandMentions: commandMentions ? { commands: commandMentions.commands, onActivate: 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 ? { 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\n }), [controller, developer, natural, 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 = 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={optimistic.model ?? viewModel} currentEffort={optimistic.effort ?? viewEffort} disabled={disabled} busy={changing}\n onModel={next => void change('model', () => model!.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => {})}\n onEffort={next => void change('effort', () => effort!.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => {})} /> : undefined;\n const barNote = changeError ? <Button variant=\"ghost\" size=\"sm\" role=\"alert\" data-testid=\"configure-error\" title={`${changeError} (${labels.dismissHint})`} onClick={() => setChangeError(null)}\n className=\"min-w-0 max-w-[16rem]\"><span className=\"truncate\">{changeError}</span></Button> : undefined;\n return { barStart, barNote };\n };\n\n const empty = connected && !state.view?.entries.length && !rows.length;\n const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;\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 working, sendBlocked, disabled: state.disposed, stopRequested: state.stop === 'requested',\n onSend: send, onStop: () => act(controller.stop),\n hasFiles: Boolean(composerHasContent),\n uploading: uploading > 0, canAttach: Boolean(onPickFiles || onFiles || attachments), ...(camera ? { camera } : {}), fileAccept: attachments ? attachments.accept ?? '' : fileAccept, onPickFiles: (files: readonly File[]) => { void upload(files); },\n slash, onSlashError: report, mentions, mentionPaths: picked, onMentionPicked: (path: string) => setPicked(value => value.includes(path) ? value : [...value, path]),\n uploads: pending, onDismissUpload: (id: string) => setPending(items => items.filter(item => item.id !== id)),\n onPasteText: attachments ? pasteText : undefined, pasteToFile,\n quote: composerHeader,\n feedback, chips: composerChips,\n };\n\n return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current, textarea, rowContext, composer, files, pickers, empty, loading, developer, natural };\n}\n\nexport type ChatSession = ReturnType<typeof useChatSession>;\n\nexport interface ChatPaging { readonly first?: number; readonly more?: number }\nexport const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;\n\n/**\n * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed\n * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.\n */\nexport function chunkKeys(previous: readonly (readonly string[])[], keys: readonly string[], size = CHUNK): string[][] {\n const owner = new Map<string, number>();\n previous.forEach((run, index) => { for (const key of run) owner.set(key, index); });\n const known = keys.findIndex(key => owner.has(key));\n const lead = known < 0 ? keys.length : known;\n const runs: string[][] = [];\n for (let end = lead; end > 0; end -= size) runs.unshift(keys.slice(Math.max(0, end - size), end));\n let current: string[] = [], from: number | undefined;\n for (const key of keys.slice(lead)) {\n const placed = owner.get(key);\n if (!current.length || (placed !== undefined ? placed !== from : current.length >= size)) { current = []; runs.push(current); from = placed; }\n current.push(key);\n }\n return runs;\n}\n\n/**\n * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it\n * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are\n * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.\n */\nexport function useRowChunks<T extends { readonly key: string }>(rows: readonly T[], size = CHUNK): readonly (readonly T[])[] {\n const previous = useRef<{ readonly keys: readonly (readonly string[])[]; readonly runs: ReadonlyMap<string, readonly T[]> }>({ keys: [], runs: new Map() });\n return useMemo(() => {\n const byKey = new Map(rows.map(row => [row.key, row]));\n const keys = chunkKeys(previous.current.keys, rows.map(row => row.key), size);\n const runs = keys.map(run => {\n const items = run.map(key => byKey.get(key)!);\n const before = previous.current.runs.get(run[0]!);\n return before && before.length === items.length && before.every((item, index) => item === items[index]) ? before : items;\n });\n previous.current = { keys, runs: new Map(runs.map(run => [run[0]!.key, run])) };\n return runs;\n }, [rows, size]);\n}\n\n/** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */\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 if (!Number.isSafeInteger(page) || page < 1) throw new TypeError('Chat displayLimit must be a positive integer');\n if (!Number.isSafeInteger(more) || more < 1) throw new TypeError('Chat paging.more must be a positive integer');\n const [limit, setLimit] = useState(page);\n const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);\n const [loaded, setLoaded] = useState(0);\n const pending = useRef(false);\n const anchor = useRef<{ readonly length: number; readonly limit: number } | undefined>(undefined);\n const { scrollToEnd } = useMessageScroller();\n const scrollable = useMessageScrollerScrollable();\n const [scroller, setScroller] = useState<HTMLDivElement | null>(null);\n const scrollToBottom = useCallback((behavior?: 'instant') => scrollToEnd({ behavior: behavior === 'instant' ? 'instant' : 'smooth' }), [scrollToEnd]);\n const stick = { scrollRef: setScroller, scrollToBottom, isAtBottom: !scrollable.end };\n const hidden = Math.max(0, rows.length - limit);\n const reveal = useCallback(() => {\n if (pending.current || anchor.current) return;\n const state = history?.state;\n if (hidden === 0 && (!history?.connected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore)) return;\n anchor.current = { length: rows.length, limit };\n // A revealed page renders as a transition: React mounts it in small slices, so scrolling stays responsive meanwhile.\n if (hidden > 0) startTransition(() => setLimit(value => value + more));\n else if (history) {\n pending.current = true;\n void Promise.resolve().then(history.load).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });\n }\n }, [hidden, history?.state, history?.connected, history?.load, rows.length, limit, more]);\n useLayoutEffect(() => {\n const saved = anchor.current;\n if (!scroller || !saved || history?.state.kind === 'loading') return;\n const added = Math.max(0, rows.length - saved.length);\n if (limit < saved.limit + added) { startTransition(() => setLimit(saved.limit + added)); return; }\n anchor.current = undefined;\n setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));\n }, [rows, limit, scroller, history?.state]);\n // Automatic paging: the sentinel near the top, once the reader has been away from the top (so the mount, which starts at the\n // top before the scroller settles at the end, loads nothing). A fast scroll lands on 0 before the sentinel is seen: the scroll\n // listener and every new observer (after each page) check again, instead of the reader's distance at that one moment.\n const near = useRef(false), away = useRef(false), revealNow = useRef(reveal);\n revealNow.current = reveal;\n useEffect(() => {\n if (!automatic || !scroller) return;\n const onScroll = () => { if (scroller.scrollTop > 8) away.current = true; if (near.current && away.current) revealNow.current(); };\n scroller.addEventListener('scroll', onScroll, { passive: true });\n return () => scroller.removeEventListener('scroll', onScroll);\n }, [automatic, scroller]);\n useEffect(() => {\n if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error') return;\n const observer = new IntersectionObserver(entries => {\n near.current = entries.at(-1)?.isIntersecting ?? near.current;\n if (near.current && (scroller.scrollHeight <= scroller.clientHeight || away.current)) reveal();\n }, { root: scroller, rootMargin: '240px 0px 0px' });\n observer.observe(sentinel);\n return () => { observer.disconnect(); near.current = false; };\n }, [automatic, scroller, sentinel, reveal, history?.state]);\n const revealTo = (key: string) => { const index = rows.findIndex(row => row.key === key); if (index >= 0) setLimit(value => Math.max(value, rows.length - index)); };\n return { stick, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: <T,>(items: readonly T[]): readonly T[] => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };\n}\n",
153
153
  "type": "registry:component",
154
154
  "target": "components/pi-chat/session.tsx"
155
155
  },
@@ -185,7 +185,7 @@
185
185
  },
186
186
  {
187
187
  "path": "registry/pi-chat/labels.ts",
188
- "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n return useMemo(() => ({ labels: withDefaults(base.labels, labels), icons: withDefaults(base.icons, icons) }), [labels, icons, base]);\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
188
+ "content": "/*\n * Every word the chat shows, and the icons it draws, as plain data. A host passes a partial `labels` (and `icons`) object to `PiChat`\n * (or `AmbientChat`, `AgentWorkspace`): what it leaves out keeps the default below. Strings that need a value are functions. No i18n\n * framework: an app that translates passes its own object per language.\n */\nimport { createContext, createElement, useContext, useMemo, useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowDownIcon, ArrowUpIcon, CameraIcon, GitForkIcon, HistoryIcon, ListEndIcon, MessagesSquareIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, SquareIcon } from 'lucide-react';\nimport type { BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\n\nconst TOOL_VERBS: Readonly<Record<string, string>> = { read: 'Reading', write: 'Writing', edit: 'Editing', ls: 'Listing', list_files: 'Listing', find: 'Searching', grep: 'Searching', load_skill: 'Loading skill', bash: 'Running bash' };\nconst TOOL_NOUNS: Readonly<Record<string, string>> = { bash: 'command', read: 'read', write: 'write', edit: 'edit', find: 'find', grep: 'search', ls: 'list' };\n\nconst EFFORT_NAMES: Readonly<Record<string, string>> = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };\nconst EFFORT_DETAILS: Readonly<Record<string, string>> = { off: 'No extra reasoning', minimal: 'Barely any reasoning', low: 'Light reasoning', medium: 'Balanced reasoning', high: 'Deep reasoning', xhigh: 'Deeper reasoning', max: 'Maximum reasoning' };\n\nexport const defaultChatLabels = {\n // Header and transcript\n /** The chat's name: the header title, the region's name and the empty state's title. */\n title: 'Conversation',\n history: 'History',\n artifacts: 'Artifacts',\n search: 'Search',\n live: 'Live',\n connecting: 'Connecting…',\n reconnecting: 'Reconnecting…',\n loadFailed: 'Conversation could not be loaded.',\n disconnected: 'Disconnected. Last observed messages remain visible.',\n reconnect: 'Reconnect',\n requiredActions: 'Required conversation actions',\n messages: 'Messages',\n showEarlier: 'Show earlier messages',\n loading: 'Loading conversation…',\n emptyDescription: 'Start a conversation.',\n jumpToLatest: 'Jump to latest',\n agentWorking: 'Agent is working',\n waitingForAnswer: 'Waiting for your answer',\n retryPending: 'Native retry pending',\n compacting: 'Native context compaction in progress',\n /** The one status of `presentation=\"natural\"` while the agent works (any tool, retry or compaction), instead of the activity wording above. */\n preparing: 'Preparing…',\n /** In `presentation=\"natural\"`, replaces the controller's own reason when a send is blocked. */\n sendUnavailable: 'Sending is not available right now.',\n /** The \"…\" menu of host actions (header, replies). */\n moreActions: 'More actions',\n // Notices\n sendUnknown: 'Submission acknowledgement is unknown. The message is kept until you check or retry it.',\n checkSubmission: 'Check original submission',\n retrySame: 'Retry same request',\n stopRequested: 'Stop requested. Waiting for native confirmation.',\n stopUnconfirmed: 'Stop is unconfirmed. Work may still be running.',\n steerRefused: 'The message could not be steered; it is back in the message box.',\n tooLate: 'Too late: that message has already started.',\n tooLateToRemove: 'Too late to remove: that message has already started.',\n queueFailed: 'The message could not be changed.',\n queueLocked: 'Queued messages cannot be changed here.',\n waitToSteer: 'Wait for the current send to finish, then steer.',\n steerFailed: 'The message could not be steered.',\n connectionFailed: 'Connection failed',\n actionFailedShort: 'Action failed',\n uploadFailedShort: 'Upload failed',\n changeRefused: 'The change was refused',\n dismissHint: 'click to dismiss',\n // Composer\n placeholder: 'Message the agent…',\n placeholderWorking: 'Queue a message…',\n messageInput: 'Message',\n send: 'Send',\n sendHint: 'Send (Enter)',\n stop: 'Stop',\n queue: 'Queue',\n add: 'Add',\n attachFiles: 'Attach files',\n takePhoto: 'Take a photo',\n cameraShoot: 'Take the photo',\n cameraClose: 'Close the camera',\n cameraUnavailable: 'The camera cannot open on this page.',\n cameraFallback: \"Use the phone's camera\",\n commands: 'Commands',\n annotate: 'Annotate',\n annotateHint: 'Annotate: click what you want to comment on (Esc to stop)',\n annotateNotes: (count: number) => `${count} ${count === 1 ? 'note' : 'notes'}`,\n annotateClear: 'Remove the notes',\n feedbackSent: (count: number) => `${count} ${count === 1 ? 'comment' : 'comments'} on the page`,\n remove: (name: string) => `Remove ${name}`,\n dismiss: (name: string) => `Dismiss ${name}`,\n uploading: (name: string) => `Uploading ${name}…`,\n uploadFailed: (name: string, error: string | undefined) => `${name}: ${error ?? 'upload failed'}`,\n preparingAttachments: 'Preparing attachments…',\n preparingAttachmentsShort: 'Preparing attachments',\n mentionedFiles: 'Mentioned files',\n uploads: 'Uploads',\n attachments: 'Attachments',\n /** The type line of a file card in a sent message. */\n fileKind: { document: 'Document', image: 'Image', pdf: 'PDF', sheet: 'Spreadsheet', slides: 'Presentation', audio: 'Audio', file: 'File' },\n openFile: (name: string) => `Open ${name}`,\n fileChange: { created: 'Created', edited: 'Edited' },\n /** The name of a pasted text file: the words and the time it was pasted. */\n pastedFileName: (stamp: string) => `Pasted text ${stamp}`,\n restorePaste: 'Put the pasted text back in the message',\n searchCommands: 'Search commands',\n filterBySource: 'Filter by source',\n allSources: 'All',\n noCommands: 'No matching commands.',\n /** A `/` menu filter chip: `skills`, `built-in` or a plugin's name. */\n slashGroup: (group: string) => group,\n skillBadge: 'skill',\n workspaceFiles: 'Workspace files',\n searchFailed: 'Search failed',\n model: 'Model',\n effort: 'Effort',\n effortDefault: 'Default',\n modelAndEffort: 'Model and effort',\n modelSummary: (model: string | undefined, effort: string | undefined) => [model && `Model: ${model}`, effort && `Effort: ${effort}`].filter(Boolean).join(', '),\n /** A thinking level's name and short description in the picker. */\n effortName: (level: string) => EFFORT_NAMES[level] ?? level,\n effortDetail: (level: string): string | undefined => EFFORT_DETAILS[level],\n // Voice input (the microphone button beside the composer)\n voiceRecord: 'Record a message',\n voiceHold: 'Hold to record, release to send',\n voiceRecording: 'Recording',\n voicePreparing: 'Preparing voice message…',\n voiceAudioOnly: 'Send audio only',\n voiceDiscard: 'Discard',\n voicePlay: 'Play voice message',\n voicePause: 'Pause voice message',\n voicePlayFailed: 'The audio could not be played. Try again.',\n voiceBlocked: 'Not sent. Review the chat notice before trying again.',\n voiceUnconfirmed: 'Message admission is unconfirmed. Use the chat notice to reconcile.',\n voiceStop: 'Stop recording',\n voiceCancel: 'Cancel recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothingHeard: 'Nothing was heard. Try again.',\n voiceTranscribeFailed: 'The recording could not be transcribed. Try again.',\n voiceDenied: 'Microphone blocked. Allow it for this site in the browser, then press record again.',\n voiceNoMicrophone: 'No microphone found. Connect one, then press record again.',\n voiceBusy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',\n voiceInsecure: 'Recording needs a secure page (https or localhost).',\n voiceUnsupported: 'This browser cannot record audio here.',\n voiceStartFailed: 'The microphone could not be started.',\n // Queue\n queuedMessages: 'Queued messages',\n steer: 'Steer',\n steerHint: 'Send this into the running turn now',\n steering: 'Steering',\n queued: 'Queued',\n removeQueued: 'Remove queued message',\n removeHint: 'Remove',\n more: 'More',\n edit: 'Edit',\n sending: 'Sending',\n images: (count: number) => `[${count} image${count === 1 ? '' : 's'}]`,\n // Conversation list and history\n historyDialog: 'Conversation history',\n newConversation: 'New',\n untitled: 'New conversation',\n closeHistory: 'Close history',\n close: 'Close',\n earlierInConversation: 'Earlier messages in this conversation',\n searchConversations: 'Search conversations',\n archived: 'Archived',\n current: 'Current',\n noMatch: (query: string) => `No conversation matches “${query}”.`,\n noArchived: 'No archived conversations.',\n noConversations: 'No conversations yet.',\n loadingConversations: 'Loading conversations…',\n actionFailed: 'That did not work. Try again.',\n rename: 'Rename',\n renameItem: (title: string) => `Rename ${title}`,\n conversationName: 'Conversation name',\n save: 'Save',\n archive: 'Archive',\n restore: 'Restore',\n archiveItem: (title: string, archived: boolean) => `${archived ? 'Restore' : 'Archive'} ${title}`,\n delete: 'Delete',\n deleteItem: (title: string) => `Delete ${title}`,\n keep: 'Keep conversation',\n today: 'Today',\n yesterday: 'Yesterday',\n previousWeek: 'Previous 7 days',\n older: 'Older',\n conversations: 'Conversations',\n earlierMessages: 'Earlier messages',\n readOnly: '(read-only)',\n retry: 'Retry',\n loadingShort: 'Loading…',\n loadEarlier: 'Load earlier',\n loadEarlierMessages: 'Load earlier messages',\n earlierMessagesLoaded: (count: number) => `${count} earlier messages loaded`,\n returnToConversation: 'Return to active conversation',\n historyFailed: 'Conversation history could not be loaded. The previous page remains visible.',\n loadPage: 'Load a page to browse earlier messages.',\n noRecords: 'No records in this history page.',\n beginning: 'Start of conversation',\n justNow: 'now',\n roleSystem: 'System',\n roleToolResult: (tool: string, failed: boolean) => `Tool result: ${tool}${failed ? ' (failed)' : ''}`,\n roleUser: 'You',\n roleAssistant: (outcome: 'ok' | 'interrupted' | 'failed'): string => outcome === 'interrupted' ? 'Assistant (interrupted)' : outcome === 'failed' ? 'Assistant (failed)' : 'Assistant',\n roleEvent: 'Event',\n contextBoundary: 'Context boundary',\n // Messages\n copy: 'Copy',\n copied: 'Copied',\n copyFailed: 'Copy failed',\n failed: 'Failed',\n copyResponse: 'Copy response',\n copyCode: 'Copy code',\n fork: 'Fork from here',\n forkHint: 'Fork from here: a new conversation with the messages up to this reply',\n responseFailed: 'The response failed',\n interrupted: 'Response interrupted',\n thinking: 'Thinking…',\n openImage: 'Open image attachment',\n imageAttachment: 'Image attachment',\n imagePosition: (index: number, count: number) => `Image ${index} of ${count}`,\n previousImage: 'Previous image',\n nextImage: 'Next image',\n systemContext: 'System context',\n toolFailed: (tool: string) => `${tool} failed`,\n event: (label: string) => `Event: ${label}`,\n // Activity and tools\n reasoning: 'Reasoning',\n thinkingStep: 'Thinking',\n steps: (count: number) => `${count} ${count === 1 ? 'step' : 'steps'}`,\n toolRunning: 'Running',\n toolCompleted: 'Completed',\n toolFailedStatus: 'Failed',\n toolUnfinished: 'No result',\n arguments: 'Arguments',\n result: 'Result',\n error: 'Error',\n openImageResult: 'Open image result',\n emptyResult: 'Empty result',\n /** The live line of a running tool call; `hint` is what it acts on (a path, a command), possibly empty. */\n toolActivity: (tool: string, hint: string) => {\n const verb = TOOL_VERBS[tool] ?? `Running ${tool}`;\n return hint ? `${verb}${tool === 'bash' ? ':' : ''} ${hint}` : verb;\n },\n /** How a tool is named in a finished activity summary (\"Used read ×3 · command\"). */\n toolNoun: (tool: string) => TOOL_NOUNS[tool] ?? tool,\n // Approval and question cards\n approvalNeeded: 'Approval needed',\n needsApproval: 'Needs your approval',\n approve: 'Approve',\n deny: 'Deny',\n approved: 'Approved',\n denied: 'Denied',\n notDecided: 'Not decided: the run was stopped',\n wantsToRun: 'The agent wants to run',\n decision: 'Decision',\n stillWritingCall: 'The agent is still writing this call…',\n decidingUnavailable: 'Deciding is not available in this view.',\n decisionSent: (approved: boolean) => `${approved ? 'Approved.' : 'Denied.'} Waiting for the agent…`,\n decisionRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This call was already decided.' : 'This call is no longer waiting for a decision.',\n decisionNotSent: 'The decision could not be sent.',\n waitingForAgent: 'Waiting for the agent…',\n question: 'Question',\n questionForYou: 'Question for you',\n notAnswered: 'Not answered',\n answered: 'Answered',\n askedQuestion: 'The agent asked a question.',\n answerOptions: 'Answer options',\n ownAnswer: 'Or type your own answer',\n typeAnswer: 'Type your answer',\n yourAnswerInput: 'Your answer',\n /** A question of a form left blank, in the answer sent to the agent. */\n skipped: '(no answer)',\n answer: 'Answer',\n stillWritingQuestion: 'The agent is still writing this question…',\n answeringUnavailable: 'Answering is not available in this view.',\n answerRefused: (kind: 'denied' | 'conflict' | 'unknown-question'): string => kind === 'denied' ? 'The host did not accept this answer.' : kind === 'conflict' ? 'This question was already answered.' : 'This question is no longer waiting for an answer.',\n answerNotSent: 'The answer could not be sent.',\n yourAnswer: 'Your answer:',\n // Run card (a headless run: `runs/<id>`)\n run: 'Run',\n runStatus: (status: 'queued' | 'running' | 'completed' | 'failed'): string => ({ queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' })[status],\n runElapsed: (time: string) => `Running for ${time}`,\n runDuration: (time: string) => `Took ${time}`,\n runRefresh: 'Refresh',\n runNotLoaded: 'The run could not be loaded.',\n // Sub-agent card (a `delegate` call)\n subagentStatus: (status: 'running' | 'done' | 'failed' | 'cancelled'): string => ({ running: 'Running', done: 'Done', failed: 'Failed', cancelled: 'Stopped' })[status],\n subagentTime: 'Time',\n subagentToolCalls: (count: number) => `${count} tool ${count === 1 ? 'call' : 'calls'}`,\n subagentTokens: (input: number, output: number) => `${input} tokens in, ${output} tokens out`,\n subagentConversation: (id: number) => `Open conversation ${id}`,\n // Artifact cards\n artifact: 'Artifact',\n artifactTypes: { markdown: 'Document', html: 'HTML page', svg: 'SVG image', code: 'Code', canvas: 'Canvas' },\n opening: 'Opening…',\n viewing: 'Viewing',\n version: (ordinal: number) => `Version ${ordinal}`,\n runCommand: (name: string, execute: boolean) => `${execute ? 'Run' : 'Insert'} /${name} command`,\n taskStatus: 'Task status',\n activityFailed: (count: number, tools: string) => `${count > 1 ? `${count} steps failed` : 'Step failed'}: ${tools}`,\n activityUsed: (summary: string, stopped: boolean) => `${stopped ? 'Stopped · ' : 'Used '}${summary}`,\n activityThought: 'Thought it through',\n activityMany: (steps: number) => `Worked through ${steps} steps`,\n open: 'Open',\n openArtifact: (title: string, version: number | undefined) => version === undefined ? `Open ${title}` : `Open ${title}, version ${version}`,\n};\n\nexport type ChatLabels = typeof defaultChatLabels;\n\n/** The chat's icons. Any component that takes `className` (every lucide icon does). */\nexport const defaultChatIcons = {\n /** The agent's glyph in the empty state (the header shows the host's `avatar`, if any). */\n agent: MessagesSquareIcon as BlockIcon,\n send: ArrowUpIcon as BlockIcon,\n stop: SquareIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n newChat: PlusIcon as BlockIcon,\n add: PlusIcon as BlockIcon,\n attach: PaperclipIcon as BlockIcon,\n camera: CameraIcon as BlockIcon,\n queue: ListEndIcon as BlockIcon,\n jumpToLatest: ArrowDownIcon as BlockIcon,\n reconnect: RefreshCwIcon as BlockIcon,\n fork: GitForkIcon as BlockIcon,\n};\n\nexport type ChatIcons = typeof defaultChatIcons;\n\n/** What the chat's pieces read: the merged labels and icons. */\nexport interface ChatText { readonly labels: ChatLabels; readonly icons: ChatIcons }\n/** The defaults, as `useMergedText`'s base. */\nexport const defaultChatText: ChatText = { labels: defaultChatLabels, icons: defaultChatIcons };\nconst DEFAULT_TEXT = defaultChatText;\nconst ChatTextContext = createContext<ChatText>(DEFAULT_TEXT);\n\n/** The labels and icons of the surrounding chat (the defaults outside one). */\nexport const useChatText = (): ChatText => useContext(ChatTextContext);\n\n/** Merges the host's partial labels and icons over the defaults (or over `base`), keeping one object while they do not change. */\nexport function useMergedText<L extends ChatLabels = ChatLabels, I extends ChatIcons = ChatIcons>(labels: Partial<L> | undefined, icons: Partial<I> | undefined,\n base: { readonly labels: L; readonly icons: I } = DEFAULT_TEXT as { readonly labels: L; readonly icons: I }): { readonly labels: L; readonly icons: I } {\n const stableLabels = useShallowStable(labels), stableIcons = useShallowStable(icons);\n return useMemo(() => ({ labels: withDefaults(base.labels, stableLabels), icons: withDefaults(base.icons, stableIcons) }), [stableLabels, stableIcons, base]);\n}\n\nconst shallowEqual = (a: unknown, b: unknown): boolean => Object.is(a, b) || (!!a && !!b && typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)\n && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])));\n/** The previous value while the new one is equal key by key (functions by identity): inline `labels={{ title }}` keeps one text context. */\nexport function useShallowStable<T>(value: T): T {\n const previous = useRef(value);\n if (!shallowEqual(previous.current, value)) previous.current = value;\n return previous.current;\n}\n\n/** Provides the chat's labels and icons to everything inside (composer, queue, rows, history). */\nexport function ChatTextProvider({ value, children }: { readonly value: ChatText; readonly children: ReactNode }) {\n return createElement(ChatTextContext.Provider, { value }, children);\n}\n",
189
189
  "type": "registry:lib",
190
190
  "target": "components/pi-chat/labels.ts"
191
191
  },