@hachej/boring-ui-kit 0.4.38 → 0.4.39

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 (52) hide show
  1. package/dist/native-chat.js +11 -1
  2. package/dist/task-board.d.ts +4 -0
  3. package/package.json +2 -2
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +10 -10
  6. package/r/board-viewer.json +6 -6
  7. package/r/button.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/feedback-preview.json +1 -1
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +4 -4
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +2 -2
  16. package/r/maquette-viewer.json +1 -1
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +8 -8
  20. package/r/pi-app-kinds.json +10 -10
  21. package/r/pi-app.json +10 -10
  22. package/r/pi-artifacts.json +2 -2
  23. package/r/pi-chat-approval.json +2 -2
  24. package/r/pi-chat-attachments.json +1 -1
  25. package/r/pi-chat-markdown.json +2 -2
  26. package/r/pi-chat-mentions.json +1 -1
  27. package/r/pi-chat-pickers.json +1 -1
  28. package/r/pi-chat-plugins.json +11 -11
  29. package/r/pi-chat-question.json +2 -2
  30. package/r/pi-chat-slash.json +2 -2
  31. package/r/pi-chat-subagent.json +1 -1
  32. package/r/pi-chat-voice.json +3 -3
  33. package/r/pi-chat.json +7 -7
  34. package/r/pi-workspace.json +3 -3
  35. package/r/placed-agent.json +4 -4
  36. package/r/pm-panel.json +18 -18
  37. package/r/provider-setup.json +1 -1
  38. package/r/registry.json +183 -183
  39. package/r/requests-board.json +1 -1
  40. package/r/shared-chat.json +8 -8
  41. package/r/task-board.json +3 -3
  42. package/r/task-list-viewer.json +2 -2
  43. package/r/task-mentions.json +4 -4
  44. package/r/version-history.json +2 -2
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +4 -4
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
@@ -9,12 +9,12 @@
9
9
  "@earendil-works/pi-ai@~1.4.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "path": "registry/pi-chat-question/question-card.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text || 'The question was not answered.' };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n return <Card role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <CardHeader>\n <CardDescription>{labels.questionForYou}</CardDescription>\n <CardTitle data-testid=\"question-text\" className=\"[overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</CardTitle>\n </CardHeader>\n <CardContent className=\"flex flex-col gap-4\">\n {question.items.length > 0 ? <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form> : <>\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {question.options.map(value => option(value, false, () => { void submit(value); }))}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n </>}\n {live && <CardDescription>{labels.stillWritingQuestion}</CardDescription>}\n {!answer && !live && <CardDescription>{labels.answeringUnavailable}</CardDescription>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </CardContent>\n </Card>;\n}\n",
17
+ "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, CircleSlashIcon, SendIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { useChatText } from '../pi-chat/labels';\nimport { ASK_USER_TOOL } from '../pi-chat/config';\nimport type { AnswerOutcome, QuestionCardProps } from '../pi-chat/config';\n\n// The core owns the tool name and the props: it places the question row and shows its waiting notice without this item.\nexport { ASK_USER_TOOL };\nexport type { AnswerOutcome, QuestionCardProps };\n\n/** One question of a form: its text and its options (chips); every question of a form also takes free text. */\nexport interface FormQuestion { readonly question: string; readonly options: readonly string[] }\ninterface Question {\n readonly question: string; readonly options: readonly string[]; readonly allowFreeText: boolean;\n /** Several questions in one form (`questions`), answered together as one text. Empty for a single question. */\n readonly items: readonly FormQuestion[];\n}\n\nconst strings = (value: unknown, max: number) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.length > 0).slice(0, max) : [];\n\n/**\n * Read `{ question, options?, allowFreeText?, questions? }` defensively: model arguments are untrusted input. Free text is allowed\n * unless `allowFreeText` is `false` (and always when there are no options). `questions` (up to 8) makes a form.\n */\nexport function parseQuestion(call: ToolCall): Question {\n const args = call.arguments as Record<string, unknown>;\n const options = strings(args['options'], 6);\n const items = Array.isArray(args['questions']) ? args['questions'].flatMap((item: unknown) => {\n const value = item !== null && typeof item === 'object' ? item as Record<string, unknown> : {};\n return typeof value['question'] === 'string' && value['question'] ? [{ question: value['question'], options: strings(value['options'], 6) }] : [];\n }).slice(0, 8) : [];\n return { question: typeof args['question'] === 'string' ? args['question'] : '', options, allowFreeText: args['allowFreeText'] !== false || options.length === 0, items };\n}\n\n/** The answers of a form as the one text the agent reads: each question, then its answer, or `skipped` when left blank. */\nexport function formAnswer(items: readonly FormQuestion[], values: readonly string[], skipped: string): string {\n return items.map((item, i) => `${i + 1}. ${item.question}\\n→ ${values[i]?.trim() || skipped}`).join('\\n\\n');\n}\n\n/** The answers of a `formAnswer` text, one per question; undefined when the text does not have that shape (then it is shown whole). */\nfunction splitFormAnswer(items: readonly FormQuestion[], text: string): readonly string[] | undefined {\n const values: string[] = [];\n let at = 0;\n for (let i = 0; i < items.length; i++) {\n const head = `${i + 1}. ${items[i]!.question}\\n→ `;\n if (!text.startsWith(head, at)) return undefined;\n at += head.length;\n const end = i + 1 < items.length ? text.indexOf(`\\n\\n${i + 2}. ${items[i + 1]!.question}\\n→ `, at) : text.length;\n if (end < 0) return undefined;\n values.push(text.slice(at, end));\n at = end + 2;\n }\n return values;\n}\n\n/** The answered text from a result such as `{\"kind\":\"answered\",\"answer\":\"...\"}`; undefined when the call did not produce an answer. */\nexport function parseAnswer(result: ToolResultMessage | undefined): { readonly answer: string } | { readonly failure: string } | undefined {\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n if (!result.isError) {\n try {\n const value: unknown = JSON.parse(text);\n if (value !== null && typeof value === 'object' && (value as { kind?: unknown }).kind === 'answered' && typeof (value as { answer?: unknown }).answer === 'string') return { answer: (value as { answer: string }).answer };\n } catch { /* fall through to the failure text */ }\n }\n return { failure: text || 'The question was not answered.' };\n}\n\n\n/**\n * Human-in-the-loop question: writing (still generated, not answerable), pending (options and free text), then answered with the chosen\n * answer. With `questions` it is one form: each question has its option chips and a free-text field, and Answer sends one text\n * (`formAnswer`). It needs only a tool call and an `answer` callback, so a host can show it outside the chat too (for example in a\n * popover answering a request of its own); wrap it in `ChatTextProvider` for the chat's words there.\n */\nexport function QuestionCard({ call, result, answer, questionId, live = false }: QuestionCardProps) {\n const { labels } = useChatText();\n const question = parseQuestion(call);\n const settled = parseAnswer(result);\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const [text, setText] = useState('');\n const [values, setValues] = useState<readonly string[]>([]);\n const formId = useId();\n const setValue = (i: number, value: string) => setValues(previous => question.items.map((_, j) => j === i ? value : previous[j] ?? ''));\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n const current = settled && 'answer' in settled ? settled.answer : sent;\n // `pending` means the question waits on the person; `writing` means the model is still generating it (options may be partial).\n const state = settled ? ('failure' in settled ? 'failed' : 'answered') : sent !== undefined ? 'sent' : live ? 'writing' : 'pending';\n const open = state === 'pending' || state === 'writing';\n\n async function submit(value: string) {\n const trimmed = value.trim();\n if (!answer || !questionId || live || !trimmed || sending !== undefined) return;\n setRefusal(undefined); setSending(trimmed);\n try {\n const outcome = await answer(questionId, trimmed);\n if (outcome.kind === 'answered') setSent(trimmed);\n else setRefusal(outcome.reason || labels.answerRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.answerNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(text); };\n const onSubmitForm = (event: FormEvent) => { event.preventDefault(); if (values.some(value => value.trim())) void submit(formAnswer(question.items, values, labels.skipped)); };\n const busy = sending !== undefined || live;\n\n const unavailable = !answer || !questionId || busy;\n // `m-0`: a host without a global preflight keeps the paragraph margin of ItemDescription.\n const wrap = 'm-0 line-clamp-none [overflow-wrap:anywhere]';\n\n if (!open) {\n const values = question.items.length > 0 && current !== undefined ? splitFormAnswer(question.items, current) : undefined;\n return <Item role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <ItemMedia variant=\"icon\">\n {state === 'answered' ? <CheckIcon role=\"img\" aria-label={labels.answered} /> : state === 'sent' ? <Spinner aria-label={labels.waitingForAgent} /> : <CircleSlashIcon role=\"img\" aria-label={labels.notAnswered} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n {state === 'failed' && <ItemDescription data-testid=\"question-text\" className={wrap}>{question.question || labels.askedQuestion}</ItemDescription>}\n {values ? <div className=\"flex min-w-0 flex-col gap-2\">{question.items.map((item, i) => <div key={i} data-testid=\"question-answer-item\" className=\"flex min-w-0 flex-col gap-1\">\n <ItemDescription className={wrap}>{item.question}</ItemDescription>\n {values[i] === labels.skipped\n ? <ItemDescription data-testid=\"question-answer\" data-skipped=\"true\" className={wrap}>{values[i]}</ItemDescription>\n : <ItemTitle data-testid=\"question-answer\" className={wrap}>{values[i]}</ItemTitle>}\n </div>)}</div>\n : current !== undefined && <ItemTitle className={wrap}><span className=\"sr-only\">{labels.yourAnswer} </span><span data-testid=\"question-answer\" className=\"whitespace-pre-line\">{current}</span></ItemTitle>}\n {state === 'failed' && settled && 'failure' in settled && <ItemDescription role=\"alert\" className={wrap}>{settled.failure}</ItemDescription>}\n </ItemContent>\n </Item>;\n }\n\n const option = (value: string, selected: boolean, onClick: () => void) => <Button key={value} variant={selected ? 'secondary' : 'outline'} data-testid=\"question-option\" data-option={value}\n aria-pressed={question.items.length > 0 ? selected : undefined} disabled={unavailable} onClick={onClick} className=\"h-auto min-h-8 w-full justify-start whitespace-normal text-start\">\n {selected && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{value}\n </Button>;\n\n return <Card role=\"group\" data-testid=\"question-card\" data-state={state} data-call-id={call.id} aria-label={labels.question}>\n <CardHeader>\n <CardDescription>{labels.questionForYou}</CardDescription>\n <CardTitle data-testid=\"question-text\" className=\"[overflow-wrap:anywhere]\">{question.question || labels.askedQuestion}</CardTitle>\n </CardHeader>\n <CardContent className=\"flex flex-col gap-4\">\n {question.items.length > 0 ? <form data-testid=\"question-form\" onSubmit={onSubmitForm} className=\"flex flex-col gap-4\">\n {question.items.map((item, i) => <div key={i} role=\"group\" aria-labelledby={`${formId}-${i}`} data-testid=\"question-item\" className=\"flex min-w-0 flex-col gap-2\">\n <p id={`${formId}-${i}`} className=\"m-0 [overflow-wrap:anywhere]\">{i + 1}. {item.question}</p>\n {item.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {item.options.map(value => option(value, values[i] === value, () => setValue(i, values[i] === value ? '' : value)))}\n </div>}\n <Input data-testid=\"question-input\" value={item.options.includes(values[i] ?? '') ? '' : values[i] ?? ''} maxLength={2000} disabled={unavailable}\n placeholder={item.options.length ? labels.ownAnswer : labels.typeAnswer} aria-label={`${labels.yourAnswerInput} ${i + 1}`} onChange={event => setValue(i, event.currentTarget.value)} />\n </div>)}\n <div><Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !values.some(value => value.trim())}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button></div>\n </form> : <>\n {question.options.length > 0 && <div role=\"group\" aria-label={labels.answerOptions} className=\"flex flex-col gap-2\">\n {question.options.map(value => option(value, false, () => { void submit(value); }))}\n </div>}\n {question.allowFreeText && <form onSubmit={onSubmit} className=\"flex gap-2\">\n <Input data-testid=\"question-input\" value={text} maxLength={2000} disabled={unavailable} placeholder={question.options.length ? labels.ownAnswer : labels.typeAnswer}\n aria-label={labels.yourAnswerInput} onChange={event => setText(event.currentTarget.value)} />\n <Button type=\"submit\" data-testid=\"question-submit\" disabled={unavailable || !text.trim()}><SendIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.answer}</Button>\n </form>}\n </>}\n {live && <CardDescription>{labels.stillWritingQuestion}</CardDescription>}\n {!answer && !live && <CardDescription>{labels.answeringUnavailable}</CardDescription>}\n {refusal && <Alert variant=\"destructive\" data-testid=\"question-refusal\"><AlertDescription>{refusal}</AlertDescription></Alert>}\n </CardContent>\n </Card>;\n}\n",
18
18
  "type": "registry:component",
19
19
  "target": "components/pi-chat-question/question-card.tsx"
20
20
  }
@@ -4,12 +4,12 @@
4
4
  "title": "Pi chat slash menu",
5
5
  "description": "The composer's `/` menu of commands and skills (and the Commands entry of the + menu). Plug it in with `slash={{ ...config, Picker: SlashPicker }}`; without it pi-chat has no `/` menu.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.39",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -9,7 +9,7 @@
9
9
  "@earendil-works/pi-ai@~1.4.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,13 +4,13 @@
4
4
  "title": "Pi chat voice messages",
5
5
  "description": "Tap-to-record voice messages: a compatibility export of the kit's chat-react/voice recorder hook, the composer's record button and the pending voice bubble, plus useHoldToRecord. Plug it in with `voiceMessage={{ ...config, ...voiceMessagePlugin }}`; without it pi-chat has no voice button.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.39",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json"
14
14
  ],
15
15
  "files": [
16
16
  {
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.38",
8
- "@hachej/boring-files@^0.4.38",
9
- "@hachej/boring-feedback@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.39",
8
+ "@hachej/boring-files@^0.4.39",
9
+ "@hachej/boring-feedback@^0.4.39",
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.38/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/button.json"
24
24
  ],
25
25
  "files": [
26
26
  {
@@ -61,7 +61,7 @@
61
61
  },
62
62
  {
63
63
  "path": "registry/pi-chat/history.tsx",
64
- "content": "'use client';\n\nimport { usePhone } from '../utils/use-media';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport { ArchiveIcon, ArchiveRestoreIcon, ArrowLeftIcon, CheckIcon, EllipsisIcon, Loader2Icon, MessageSquareIcon, PencilIcon, SearchIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { targetContent } from './rows';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction, MenuItem } from '../button/actions';\nimport type { ConversationItem, ConversationsConfig } from './config';\nimport { cn } from '../utils/utils';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nconst DAY = 86_400_000;\nconst toMs = (value: ConversationItem['updatedAt']): number | undefined => {\n if (value === undefined) return undefined;\n const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);\n return Number.isFinite(ms) ? ms : undefined;\n};\n\n/** Short relative time for a list row: \"now\", \"5m\", \"3h\", \"Yesterday\", \"4d\", then a date. */\n/** The tooltip's last message without a leading `<target>` block (point-and-speak); a preview clipped inside the block gets no tooltip. */\nconst lastSaid = (text: string | null | undefined): string | undefined => !text ? undefined : text.startsWith('<target>') ? targetContent(text)?.speech || undefined : text;\n\nexport function relativeTime(value: ConversationItem['updatedAt'], now = Date.now(), labels: Pick<ChatLabels, 'justNow' | 'yesterday'> = defaultChatLabels): string {\n const ms = toMs(value);\n if (ms === undefined) return '';\n const age = Math.max(0, now - ms);\n if (age < 60_000) return labels.justNow;\n if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`;\n if (age < DAY) return `${Math.floor(age / 3_600_000)}h`;\n const days = Math.floor(age / DAY);\n if (days === 1) return labels.yesterday;\n if (days < 7) return `${days}d`;\n return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });\n}\n\ntype Group = 'today' | 'yesterday' | 'previousWeek' | 'older' | 'conversations';\nfunction groupOf(value: ConversationItem['updatedAt'], now: number): Group {\n const ms = toMs(value);\n if (ms === undefined) return 'conversations';\n const start = new Date(now); start.setHours(0, 0, 0, 0);\n const age = start.getTime() - ms;\n return age <= 0 ? 'today' : age <= DAY ? 'yesterday' : age < 7 * DAY ? 'previousWeek' : 'older';\n}\nconst ORDER: readonly Group[] = ['today', 'yesterday', 'previousWeek', 'older', 'conversations'];\n\n/** The row's \"…\" menu: host actions in the consumer's shadcn dropdown-menu. */\nfunction RowActions({ label, items }: { readonly label: string; readonly items: readonly MenuItem[] }) {\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={node => { portal.current = node?.ownerDocument.body ?? null; }} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid=\"conversation-action-more\"><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent align=\"end\" aria-label={label} data-testid=\"conversation-action-more-list\" className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {items.map(item => <Fragment key={item.id}>\n {item.separatorBefore && <DropdownMenuSeparator />}\n <DropdownMenuItem variant={item.destructive ? 'destructive' : 'default'} disabled={item.disabled} data-testid={item.testId ?? `conversation-action-more-${item.id}`} data-item={item.id} onClick={item.onSelect} className=\"min-h-8 max-md:min-h-10 pointer-coarse:min-h-10\">\n {item.icon}{item.label}\n </DropdownMenuItem>\n </Fragment>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\n/**\n * The searchable list of past conversations: a search box (server-side when the host offers `search`), the Archived filter, rows grouped by\n * recency with the open one marked, and the row actions the host offers (rename, archive, delete). It fills its container; `ConversationHistory`\n * puts it in a popover or sheet, the `pi-app` block in its sessions pane. `onPicked` runs after a row is chosen.\n */\nexport function ConversationList({ conversations, onPicked, autoFocus = false, searchMode = 'disclosure', rowActionLayout = 'menu', navigation }: {\n readonly conversations: ConversationsConfig;\n /** `inline` retains the always-visible search field. */\n readonly searchMode?: 'disclosure' | 'inline';\n /** `inline` retains the separate rename/archive/delete buttons. */\n readonly rowActionLayout?: 'menu' | 'inline';\n readonly navigation?: ReactNode;\n readonly onPicked?: (() => void) | undefined;\n /** Focus the search box on mount (not on touch screens). */\n readonly autoFocus?: boolean;\n}) {\n const { labels } = useChatText();\n const [query, setQuery] = useState('');\n const [searchOpen, setSearchOpen] = useState(autoFocus || searchMode === 'inline');\n const [archived, setArchived] = useState(false);\n // Server-side results while a query is typed or the Archived filter is on (when the host offers `search`).\n const [found, setFound] = useState<{ readonly items: readonly ConversationItem[]; readonly loading: boolean } | null>(null);\n const [revision, setRevision] = useState(0);\n const [renaming, setRenaming] = useState<string | null>(null);\n const [confirming, setConfirming] = useState<string | null>(null);\n const [failure, setFailure] = useState<string | null>(null);\n const search = useRef<HTMLInputElement>(null);\n // The time the list arrived: rows group and age against it until the host sends new items.\n // eslint-disable-next-line react-hooks/purity, react-hooks/exhaustive-deps -- read once per items list on purpose, so rows keep one clock between list updates\n const now = useMemo(() => Date.now(), [conversations.items]);\n useEffect(() => { if (autoFocus && !globalThis.matchMedia?.('(pointer: coarse)').matches) search.current?.focus(); }, [autoFocus]);\n const serverSearch = conversations.search;\n useEffect(() => {\n const needle = query.trim();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- leaving server search drops its results once, after commit\n if (!serverSearch || (!needle && !archived)) { setFound(null); return; }\n const controller = new AbortController();\n setFound(previous => ({ items: previous?.items ?? [], loading: true }));\n const timer = setTimeout(() => {\n serverSearch(needle, { archived }, controller.signal).then(items => { if (!controller.signal.aborted) setFound({ items, loading: false }); })\n .catch(() => { if (!controller.signal.aborted) setFound({ items: [], loading: false }); });\n }, 150);\n return () => { clearTimeout(timer); controller.abort(); };\n }, [serverSearch, query, archived, revision]);\n /** Runs one row action; the host refreshes `items`, and a server-side search runs again. */\n const run = (action: () => Promise<void>) => {\n setFailure(null);\n action().then(() => setRevision(value => value + 1), () => setFailure(labels.actionFailed));\n };\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const items = found ? found.items : conversations.items.filter(item => !item.archived && (!needle || (item.title ?? labels.untitled).toLowerCase().includes(needle)));\n const sorted = [...items].sort((a, b) => (toMs(b.updatedAt) ?? 0) - (toMs(a.updatedAt) ?? 0));\n const groups = new Map<Group, ConversationItem[]>();\n for (const item of sorted) { const key = groupOf(item.updatedAt, now); groups.set(key, [...(groups.get(key) ?? []), item]); }\n return ORDER.flatMap(key => groups.has(key) ? [{ key, items: groups.get(key)! }] : []);\n }, [conversations.items, found, query, now, labels.untitled]);\n const loading = found ? found.loading : conversations.loading;\n const total = shown.reduce((sum, group) => sum + group.items.length, 0);\n const rowActionItems = (item: ConversationItem, extraButtons: readonly BlockAction[], extraMenu: readonly BlockAction[]): MenuItem[] => [\n ...(conversations.rename ? [{ id: 'rename', icon: <PencilIcon />, label: labels.rename, testId: 'conversation-rename', onSelect: () => setRenaming(item.id) }] : []),\n ...(conversations.archive ? [{ id: 'archive', icon: item.archived ? <ArchiveRestoreIcon /> : <ArchiveIcon />, label: item.archived ? labels.restore : labels.archive, testId: 'conversation-archive', onSelect: () => run(() => conversations.archive!(item.id, !item.archived)) }] : []),\n ...(conversations.remove ? [{ id: 'delete', icon: <Trash2Icon />, destructive: true, separatorBefore: true, label: labels.delete, testId: 'conversation-delete', onSelect: () => setConfirming(item.id) }] : []),\n ...[...extraButtons, ...extraMenu].map(entry => actionMenuItem(entry, 'conversation-action')),\n ];\n return <>\n {!searchOpen && <Button variant=\"ghost\" data-testid=\"conversation-search-open\" className=\"mx-2 justify-start\" onClick={() => { setSearchOpen(true); requestAnimationFrame(() => search.current?.focus()); }}><SearchIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.search}</Button>}\n {searchOpen && <div className=\"shrink-0 px-3 py-2\">\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput ref={search} type=\"search\" value={query} onChange={event => setQuery(event.currentTarget.value)} placeholder={labels.searchConversations} aria-label={labels.searchConversations} data-testid=\"conversation-search\" />\n {conversations.archive && serverSearch && <InputGroupAddon align=\"inline-end\"><Toggle pressed={archived} aria-label={labels.archived} title={labels.archived} data-testid=\"conversations-archived\"\n onPressedChange={setArchived}><ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.archived}</Toggle></InputGroupAddon>}\n </InputGroup>\n </div>}\n {navigation}\n {failure && <p role=\"alert\" className=\"m-0 shrink-0 px-3 pt-2 text-xs text-destructive\">{failure}</p>}\n <ScrollArea className=\"min-h-0 flex-1\" aria-busy={loading ? 'true' : undefined}>\n {total === 0 && <p role=\"status\" data-testid=\"conversations-empty\" className=\"m-0 px-3 py-8 text-center text-sm text-muted-foreground\">\n {loading ? labels.loadingConversations : query.trim() ? labels.noMatch(query.trim()) : archived ? labels.noArchived : labels.noConversations}</p>}\n {shown.map(group => <SidebarGroup key={group.key} aria-label={labels[group.key]} role=\"group\">\n <SidebarGroupLabel render={<h4 />}>{labels[group.key]}</SidebarGroupLabel>\n <SidebarMenu>\n {group.items.map(item => {\n const active = item.id === conversations.activeId;\n const title = (item.title?.startsWith('<target>') ? lastSaid(item.title) : item.title)?.trim() || labels.untitled;\n const extra = conversations.rowActions?.(item);\n // A row has room for icons only: a header action without an icon goes in the row's \"…\" menu.\n const extraButtons = headerActions(extra).filter(entry => entry.icon), extraMenu = [...headerActions(extra).filter(entry => !entry.icon), ...menuActions(extra)];\n if (renaming === item.id && conversations.rename) {\n const rename = conversations.rename;\n return <SidebarMenuItem key={item.id}>\n <form className=\"flex items-center gap-2\" onSubmit={event => {\n event.preventDefault();\n const value = new FormData(event.currentTarget).get('title')?.toString().trim();\n setRenaming(null);\n if (value && value !== item.title) run(() => rename(item.id, value));\n }}>\n <PencilIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <Input name=\"title\" autoFocus defaultValue={title} maxLength={200} aria-label={labels.conversationName} data-testid=\"conversation-rename-input\"\n onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); } }}\n className=\"min-w-0 flex-1\" />\n <Button type=\"submit\" size=\"sm\" variant=\"outline\" data-testid=\"conversation-rename-save\">{labels.save}</Button>\n </form>\n </SidebarMenuItem>;\n }\n return <SidebarMenuItem key={item.id} className=\"flex items-center gap-0.5\">\n <Button type=\"button\" variant={active ? 'secondary' : 'ghost'} data-testid=\"conversation-row\" data-conversation-id={item.id} data-active={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}\n title={lastSaid(item.lastMessage)}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className=\"h-auto min-h-10 min-w-0 flex-1 justify-start max-md:min-h-12\">\n <MessageSquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{title}</span>\n {active ? <span className=\"inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground\"><CheckIcon className=\"size-3.5\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.current}</span></span>\n : <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">{relativeTime(item.updatedAt, now, labels)}</span>}\n </Button>\n {(conversations.rename || conversations.archive || conversations.remove || extraButtons.length > 0 || extraMenu.length > 0) && <div data-testid=\"conversation-actions\"\n className={cn('flex shrink-0 items-center gap-0.5', confirming === item.id ? '' : 'opacity-0 group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 pointer-coarse:opacity-100')}>\n {confirming === item.id && conversations.remove ? <>\n <Button size=\"sm\" variant=\"destructive\" data-testid=\"conversation-delete-confirm\" onClick={() => { const remove = conversations.remove!; setConfirming(null); run(() => remove(item.id)); }}>{labels.delete}</Button>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.keep} title={labels.keep} onClick={() => setConfirming(null)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </> : rowActionLayout === 'menu' ? <RowActions label={labels.moreActions} items={rowActionItems(item, extraButtons, extraMenu)} /> : <>\n {conversations.rename && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.renameItem(title)} title={labels.rename} data-testid=\"conversation-rename\" onClick={() => setRenaming(item.id)}><PencilIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {conversations.archive && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.archiveItem(title, item.archived === true)} title={item.archived ? labels.restore : labels.archive} data-testid=\"conversation-archive\"\n onClick={() => { const archive = conversations.archive!; run(() => archive(item.id, !item.archived)); }}>{item.archived ? <ArchiveRestoreIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>}\n {conversations.remove && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.deleteItem(title)} title={labels.delete} data-testid=\"conversation-delete\" onClick={() => setConfirming(item.id)}><Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {extraButtons.map(entry => <ActionButton key={entry.id} action={entry} testId=\"conversation-action\" />)}\n {extraMenu.length > 0 && <RowActions label={labels.moreActions} items={extraMenu.map(entry => actionMenuItem(entry, 'conversation-action'))} />}\n </>}\n </div>}\n </SidebarMenuItem>;\n })}\n </SidebarMenu>\n </SidebarGroup>)}\n </ScrollArea>\n </>;\n}\n\n/**\n * The list of past conversations in a dialog: a header with New, then `ConversationList`.\n * An anchored card on a wide screen and a bottom drawer on a phone. Escape or a click outside closes it.\n */\nexport function ConversationHistory({ conversations, onClose, placement = 'below' }: {\n readonly conversations: ConversationsConfig;\n readonly onClose: () => void;\n /** `above` opens the card upwards from a bar docked at the bottom of the screen; the backdrop then covers the whole page. */\n readonly placement?: 'below' | 'above';\n}) {\n const { labels, icons } = useChatText();\n const phone = usePhone();\n useEffect(() => {\n const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [onClose]);\n const content = <>\n <header className=\"flex shrink-0 items-center gap-2 px-3 py-2.5 max-md:pt-[max(0.625rem,env(safe-area-inset-top))]\">\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} data-testid=\"conversations-close\" onClick={onClose} className=\"md:hidden\"><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <h3 className=\"m-0 min-w-0 flex-1 text-sm font-semibold\">{labels.history}</h3>\n {conversations.onNew && <Button size=\"sm\" variant=\"outline\" data-testid=\"conversation-new\" onClick={() => { conversations.onNew!(); onClose(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newConversation}</Button>}\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} onClick={onClose} className=\"max-md:hidden\"><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <ConversationList conversations={conversations} onPicked={onClose} autoFocus />\n </>;\n return phone ? <Drawer open showSwipeHandle onOpenChange={next => { if (!next) onClose(); }}><DrawerContent data-testid=\"conversations\" aria-label={labels.historyDialog} className=\"pi-chat min-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{labels.historyDialog}</DrawerTitle><div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{content}</div></DrawerContent></Drawer>\n : <Popover open onOpenChange={next => { if (!next) onClose(); }} modal={false}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className={cn('pointer-events-none absolute', placement === 'above' ? 'bottom-0 left-0' : 'top-12 right-3')} />} />\n <PopoverContent role=\"dialog\" aria-label={labels.historyDialog} data-testid=\"conversations\" side={placement === 'above' ? 'top' : 'bottom'} align={placement === 'above' ? 'start' : 'end'} className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{content}</PopoverContent>\n </Popover>;\n}\n\n/** The only history chrome inside the normal transcript: a sentinel, spinner and accessible fallback. */\nexport function TranscriptHistory({ history, connected, onLoad, sentinelRef, hidden, loaded }: {\n readonly history: ChatHistoryState;\n readonly connected: boolean;\n readonly onLoad: () => void;\n readonly sentinelRef: (element: HTMLDivElement | null) => void;\n readonly hidden: number;\n readonly loaded: number;\n}) {\n const { labels } = useChatText();\n if (history.kind === 'disabled' && hidden === 0) return null;\n const exhausted = hidden === 0 && history.kind === 'ready' && !history.hasMore;\n return <div ref={sentinelRef} data-testid=\"transcript-history\" className=\"flex min-h-8 shrink-0 items-center justify-center text-xs text-muted-foreground\">\n <Button variant=\"ghost\" data-testid=\"transcript-history-load\" className=\"sr-only focus:not-sr-only\" disabled={!connected || history.kind === 'loading' || exhausted} onClick={onLoad}>{labels.loadEarlierMessages}</Button>\n {history.kind === 'loading' && <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-label={labels.loadingShort} />}\n {history.kind === 'error' && <span role=\"alert\">{labels.historyFailed}</span>}\n {exhausted && <span>{labels.beginning}</span>}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{loaded > 0 ? labels.earlierMessagesLoaded(loaded) : ''}</span>\n </div>;\n}\n",
64
+ "content": "'use client';\n\nimport { usePhone } from '../utils/use-media';\n\nimport { Fragment, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChatHistoryState } from '@hachej/boring-ui-kit/native-chat';\nimport { ArchiveIcon, ArchiveRestoreIcon, ArrowLeftIcon, CheckIcon, EllipsisIcon, Loader2Icon, MessageSquareIcon, PencilIcon, SearchIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { targetContent } from './rows';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Input } from '@/components/ui/input';\nimport { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem } from '@/components/ui/sidebar';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction, MenuItem } from '../button/actions';\nimport type { ConversationItem, ConversationsConfig } from './config';\nimport { cn } from '../utils/utils';\nimport { defaultChatLabels, useChatText } from './labels';\nimport type { ChatLabels } from './labels';\n\nconst DAY = 86_400_000;\nconst toMs = (value: ConversationItem['updatedAt']): number | undefined => {\n if (value === undefined) return undefined;\n const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);\n return Number.isFinite(ms) ? ms : undefined;\n};\n\n/** Short relative time for a list row: \"now\", \"5m\", \"3h\", \"Yesterday\", \"4d\", then a date. */\n/** The tooltip's last message without a leading `<target>` block (point-and-speak); a preview clipped inside the block gets no tooltip. */\nconst lastSaid = (text: string | null | undefined): string | undefined => !text ? undefined : text.startsWith('<target>') ? targetContent(text)?.speech || undefined : text;\n\nexport function relativeTime(value: ConversationItem['updatedAt'], now = Date.now(), labels: Pick<ChatLabels, 'justNow' | 'yesterday'> = defaultChatLabels): string {\n const ms = toMs(value);\n if (ms === undefined) return '';\n const age = Math.max(0, now - ms);\n if (age < 60_000) return labels.justNow;\n if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`;\n if (age < DAY) return `${Math.floor(age / 3_600_000)}h`;\n const days = Math.floor(age / DAY);\n if (days === 1) return labels.yesterday;\n if (days < 7) return `${days}d`;\n return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });\n}\n\ntype Group = 'today' | 'yesterday' | 'previousWeek' | 'older' | 'conversations';\nfunction groupOf(value: ConversationItem['updatedAt'], now: number): Group {\n const ms = toMs(value);\n if (ms === undefined) return 'conversations';\n const start = new Date(now); start.setHours(0, 0, 0, 0);\n const age = start.getTime() - ms;\n return age <= 0 ? 'today' : age <= DAY ? 'yesterday' : age < 7 * DAY ? 'previousWeek' : 'older';\n}\nconst ORDER: readonly Group[] = ['today', 'yesterday', 'previousWeek', 'older', 'conversations'];\n\n/** The row's \"…\" menu: host actions in the consumer's shadcn dropdown-menu. */\nfunction RowActions({ label, items }: { readonly label: string; readonly items: readonly MenuItem[] }) {\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={node => { portal.current = node?.ownerDocument.body ?? null; }} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid=\"conversation-action-more\"><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent align=\"end\" aria-label={label} data-testid=\"conversation-action-more-list\" className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {items.map(item => <Fragment key={item.id}>\n {item.separatorBefore && <DropdownMenuSeparator />}\n <DropdownMenuItem variant={item.destructive ? 'destructive' : 'default'} disabled={item.disabled} data-testid={item.testId ?? `conversation-action-more-${item.id}`} data-item={item.id} onClick={item.onSelect} className=\"min-h-8 max-md:min-h-10 pointer-coarse:min-h-10\">\n {item.icon}{item.label}\n </DropdownMenuItem>\n </Fragment>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\n/**\n * The searchable list of past conversations: a search box (server-side when the host offers `search`), the Archived filter, rows grouped by\n * recency with the open one marked, and the row actions the host offers (rename, archive, delete). It fills its container; `ConversationHistory`\n * puts it in a popover or sheet, the `pi-app` block in its sessions pane. `onPicked` runs after a row is chosen.\n */\nexport function ConversationList({ conversations, onPicked, autoFocus = false, searchMode = 'disclosure', rowActionLayout = 'menu', navigation }: {\n readonly conversations: ConversationsConfig;\n /** `inline` retains the always-visible search field. */\n readonly searchMode?: 'disclosure' | 'inline';\n /** `inline` retains the separate rename/archive/delete buttons. */\n readonly rowActionLayout?: 'menu' | 'inline';\n readonly navigation?: ReactNode;\n readonly onPicked?: (() => void) | undefined;\n /** Focus the search box on mount (not on touch screens). */\n readonly autoFocus?: boolean;\n}) {\n const { labels } = useChatText();\n const [query, setQuery] = useState('');\n const [searchOpen, setSearchOpen] = useState(autoFocus || searchMode === 'inline');\n const [archived, setArchived] = useState(false);\n // Server-side results while a query is typed or the Archived filter is on (when the host offers `search`).\n const [found, setFound] = useState<{ readonly items: readonly ConversationItem[]; readonly loading: boolean } | null>(null);\n const [revision, setRevision] = useState(0);\n const [renaming, setRenaming] = useState<string | null>(null);\n const [confirming, setConfirming] = useState<string | null>(null);\n const [failure, setFailure] = useState<string | null>(null);\n const search = useRef<HTMLInputElement>(null);\n // The time the list arrived: rows group and age against it until the host sends new items.\n // eslint-disable-next-line react-hooks/purity, react-hooks/exhaustive-deps -- read once per items list on purpose, so rows keep one clock between list updates\n const now = useMemo(() => Date.now(), [conversations.items]);\n useEffect(() => { if (autoFocus && !globalThis.matchMedia?.('(pointer: coarse)').matches) search.current?.focus(); }, [autoFocus]);\n const serverSearch = conversations.search;\n useEffect(() => {\n const needle = query.trim();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- leaving server search drops its results once, after commit\n if (!serverSearch || (!needle && !archived)) { setFound(null); return; }\n const controller = new AbortController();\n setFound(previous => ({ items: previous?.items ?? [], loading: true }));\n const timer = setTimeout(() => {\n serverSearch(needle, { archived }, controller.signal).then(items => { if (!controller.signal.aborted) setFound({ items, loading: false }); })\n .catch(() => { if (!controller.signal.aborted) setFound({ items: [], loading: false }); });\n }, 150);\n return () => { clearTimeout(timer); controller.abort(); };\n }, [serverSearch, query, archived, revision]);\n /** Runs one row action; the host refreshes `items`, and a server-side search runs again. */\n const run = (action: () => Promise<void>) => {\n setFailure(null);\n action().then(() => setRevision(value => value + 1), () => setFailure(labels.actionFailed));\n };\n const shown = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const items = found ? found.items : conversations.items.filter(item => !item.archived && (!needle || (item.title ?? labels.untitled).toLowerCase().includes(needle)));\n const sorted = [...items].sort((a, b) => (toMs(b.updatedAt) ?? 0) - (toMs(a.updatedAt) ?? 0));\n const groups = new Map<Group, ConversationItem[]>();\n for (const item of sorted) { const key = groupOf(item.updatedAt, now); groups.set(key, [...(groups.get(key) ?? []), item]); }\n return ORDER.flatMap(key => groups.has(key) ? [{ key, items: groups.get(key)! }] : []);\n }, [conversations.items, found, query, now, labels.untitled]);\n const loading = found ? found.loading : conversations.loading;\n const total = shown.reduce((sum, group) => sum + group.items.length, 0);\n const rowActionItems = (item: ConversationItem, extraButtons: readonly BlockAction[], extraMenu: readonly BlockAction[]): MenuItem[] => [\n ...(conversations.rename ? [{ id: 'rename', icon: <PencilIcon />, label: labels.rename, testId: 'conversation-rename', onSelect: () => setRenaming(item.id) }] : []),\n ...(conversations.archive ? [{ id: 'archive', icon: item.archived ? <ArchiveRestoreIcon /> : <ArchiveIcon />, label: item.archived ? labels.restore : labels.archive, testId: 'conversation-archive', onSelect: () => run(() => conversations.archive!(item.id, !item.archived)) }] : []),\n ...(conversations.remove ? [{ id: 'delete', icon: <Trash2Icon />, destructive: true, separatorBefore: true, label: labels.delete, testId: 'conversation-delete', onSelect: () => setConfirming(item.id) }] : []),\n ...[...extraButtons, ...extraMenu].map(entry => actionMenuItem(entry, 'conversation-action')),\n ];\n return <>\n {!searchOpen && <Button variant=\"ghost\" data-testid=\"conversation-search-open\" className=\"mx-2 justify-start\" onClick={() => { setSearchOpen(true); requestAnimationFrame(() => search.current?.focus()); }}><SearchIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.search}</Button>}\n {searchOpen && <div className=\"shrink-0 px-3 py-2\">\n <InputGroup>\n <InputGroupAddon><SearchIcon aria-hidden=\"true\" /></InputGroupAddon>\n <InputGroupInput ref={search} type=\"search\" value={query} onChange={event => setQuery(event.currentTarget.value)} placeholder={labels.searchConversations} aria-label={labels.searchConversations} data-testid=\"conversation-search\" />\n {conversations.archive && serverSearch && <InputGroupAddon align=\"inline-end\"><Toggle pressed={archived} aria-label={labels.archived} title={labels.archived} data-testid=\"conversations-archived\"\n onPressedChange={setArchived}><ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.archived}</Toggle></InputGroupAddon>}\n </InputGroup>\n </div>}\n {navigation}\n {failure && <p role=\"alert\" className=\"m-0 shrink-0 px-3 pt-2 text-xs text-destructive\">{failure}</p>}\n <ScrollArea className=\"min-h-0 flex-1\" aria-busy={loading ? 'true' : undefined}>\n {total === 0 && <p role=\"status\" data-testid=\"conversations-empty\" className=\"m-0 px-3 py-8 text-center text-sm text-muted-foreground\">\n {loading ? labels.loadingConversations : query.trim() ? labels.noMatch(query.trim()) : archived ? labels.noArchived : labels.noConversations}</p>}\n {shown.map(group => <SidebarGroup key={group.key} aria-label={labels[group.key]} role=\"group\">\n <SidebarGroupLabel render={<h4 />}>{labels[group.key]}</SidebarGroupLabel>\n <SidebarMenu>\n {group.items.map(item => {\n const active = item.id === conversations.activeId;\n const title = (item.title?.startsWith('<target>') ? lastSaid(item.title) : item.title)?.trim() || labels.untitled;\n const extra = conversations.rowActions?.(item);\n // A row has room for icons only: a header action without an icon goes in the row's \"…\" menu.\n const extraButtons = headerActions(extra).filter(entry => entry.icon), extraMenu = [...headerActions(extra).filter(entry => !entry.icon), ...menuActions(extra)];\n if (renaming === item.id && conversations.rename) {\n const rename = conversations.rename;\n return <SidebarMenuItem key={item.id}>\n <form className=\"flex items-center gap-2\" onSubmit={event => {\n event.preventDefault();\n const value = new FormData(event.currentTarget).get('title')?.toString().trim();\n setRenaming(null);\n if (value && value !== item.title) run(() => rename(item.id, value));\n }}>\n <PencilIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <Input name=\"title\" autoFocus defaultValue={title} maxLength={200} aria-label={labels.conversationName} data-testid=\"conversation-rename-input\"\n onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); } }}\n className=\"min-w-0 flex-1\" />\n <Button type=\"submit\" size=\"sm\" variant=\"outline\" data-testid=\"conversation-rename-save\">{labels.save}</Button>\n </form>\n </SidebarMenuItem>;\n }\n return <SidebarMenuItem key={item.id} className=\"flex items-center gap-0.5\">\n <Button type=\"button\" variant={active ? 'secondary' : 'ghost'} data-testid=\"conversation-row\" data-conversation-id={item.id} data-active={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}\n title={lastSaid(item.lastMessage)}\n onClick={() => { if (!active) conversations.onSelect(item.id); onPicked?.(); }}\n className=\"h-auto min-h-10 min-w-0 flex-1 justify-start max-md:min-h-12\">\n <MessageSquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n <span className=\"min-w-0 flex-1 truncate text-left\">{title}</span>\n {active ? <span className=\"inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground\"><CheckIcon className=\"size-3.5\" aria-hidden=\"true\" /><span className=\"sr-only\">{labels.current}</span></span>\n : <span className=\"shrink-0 text-xs text-muted-foreground tabular-nums\">{relativeTime(item.updatedAt, now, labels)}</span>}\n </Button>\n {(conversations.rename || conversations.archive || conversations.remove || extraButtons.length > 0 || extraMenu.length > 0) && <div data-testid=\"conversation-actions\"\n className={cn('flex shrink-0 items-center gap-0.5', confirming === item.id ? '' : 'opacity-0 group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 pointer-coarse:opacity-100')}>\n {confirming === item.id && conversations.remove ? <>\n <Button size=\"sm\" variant=\"destructive\" data-testid=\"conversation-delete-confirm\" onClick={() => { const remove = conversations.remove!; setConfirming(null); run(() => remove(item.id)); }}>{labels.delete}</Button>\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.keep} title={labels.keep} onClick={() => setConfirming(null)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </> : rowActionLayout === 'menu' ? <RowActions label={labels.moreActions} items={rowActionItems(item, extraButtons, extraMenu)} /> : <>\n {conversations.rename && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.renameItem(title)} title={labels.rename} data-testid=\"conversation-rename\" onClick={() => setRenaming(item.id)}><PencilIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {conversations.archive && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.archiveItem(title, item.archived === true)} title={item.archived ? labels.restore : labels.archive} data-testid=\"conversation-archive\"\n onClick={() => { const archive = conversations.archive!; run(() => archive(item.id, !item.archived)); }}>{item.archived ? <ArchiveRestoreIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <ArchiveIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}</Button>}\n {conversations.remove && <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.deleteItem(title)} title={labels.delete} data-testid=\"conversation-delete\" onClick={() => setConfirming(item.id)}><Trash2Icon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {extraButtons.map(entry => <ActionButton key={entry.id} action={entry} testId=\"conversation-action\" />)}\n {extraMenu.length > 0 && <RowActions label={labels.moreActions} items={extraMenu.map(entry => actionMenuItem(entry, 'conversation-action'))} />}\n </>}\n </div>}\n </SidebarMenuItem>;\n })}\n </SidebarMenu>\n </SidebarGroup>)}\n </ScrollArea>\n </>;\n}\n\n/**\n * The list of past conversations in a dialog: a header with New, then `ConversationList`.\n * An anchored card on a wide screen and a bottom drawer on a phone. Escape or a click outside closes it.\n */\nexport function ConversationHistory({ conversations, onClose, placement = 'below' }: {\n readonly conversations: ConversationsConfig;\n readonly onClose: () => void;\n /** `above` opens the card upwards from a bar docked at the bottom of the screen; the backdrop then covers the whole page. */\n readonly placement?: 'below' | 'above';\n}) {\n const { labels, icons } = useChatText();\n const phone = usePhone();\n useEffect(() => {\n const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };\n document.addEventListener('keydown', escape);\n return () => document.removeEventListener('keydown', escape);\n }, [onClose]);\n const content = <>\n <header className=\"flex shrink-0 items-center gap-2 px-3 py-2.5 max-md:pt-[max(0.625rem,env(safe-area-inset-top))]\">\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} data-testid=\"conversations-close\" onClick={onClose} className=\"md:hidden\"><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <h3 className=\"m-0 min-w-0 flex-1 text-sm font-semibold\">{labels.history}</h3>\n {conversations.onNew && <Button size=\"sm\" variant=\"outline\" data-testid=\"conversation-new\" onClick={() => { conversations.onNew!(); onClose(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newConversation}</Button>}\n <Button size=\"icon-sm\" variant=\"ghost\" aria-label={labels.closeHistory} title={labels.close} onClick={onClose} className=\"max-md:hidden\"><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <ConversationList conversations={conversations} onPicked={onClose} autoFocus />\n </>;\n return phone ? <Drawer open showSwipeHandle onOpenChange={next => { if (!next) onClose(); }}><DrawerContent data-testid=\"conversations\" aria-label={labels.historyDialog} className=\"pi-chat min-h-[80dvh]\"><DrawerTitle className=\"sr-only\">{labels.historyDialog}</DrawerTitle><div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{content}</div></DrawerContent></Drawer>\n : <Popover open onOpenChange={next => { if (!next) onClose(); }} modal={false}>\n <PopoverTrigger nativeButton={false} render={<span aria-hidden=\"true\" className={cn('pointer-events-none absolute', placement === 'above' ? 'bottom-0 left-0' : 'top-12 right-3')} />} />\n <PopoverContent role=\"dialog\" aria-label={labels.historyDialog} data-testid=\"conversations\" side={placement === 'above' ? 'top' : 'bottom'} align={placement === 'above' ? 'start' : 'end'} className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(34rem,70dvh)]\">{content}</PopoverContent>\n </Popover>;\n}\n\n/** The only history chrome inside the normal transcript: a sentinel, spinner and accessible fallback. */\nexport function TranscriptHistory({ history, connected, onLoad, sentinelRef, hidden, loaded }: {\n readonly history: ChatHistoryState;\n readonly connected: boolean;\n readonly onLoad: () => void;\n readonly sentinelRef: (element: HTMLDivElement | null) => void;\n readonly hidden: number;\n readonly loaded: number;\n}) {\n const { labels } = useChatText();\n if (history.kind === 'disabled' && hidden === 0) return null;\n const exhausted = hidden === 0 && history.kind === 'ready' && !history.hasMore;\n return <div ref={sentinelRef} data-testid=\"transcript-history\" className=\"flex min-h-8 shrink-0 items-center justify-center text-xs text-muted-foreground\">\n {!exhausted && <Button variant=\"ghost\" data-testid=\"transcript-history-load\" className={history.kind === 'error' ? undefined : 'sr-only focus:not-sr-only'} disabled={!connected || history.kind === 'loading'} onClick={onLoad}>{labels.loadEarlierMessages}</Button>}\n {history.kind === 'loading' && <Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-label={labels.loadingShort} />}\n {history.kind === 'error' && <span role=\"alert\">{labels.historyFailed}</span>}\n {exhausted && <span>{labels.beginning}</span>}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">{loaded > 0 ? labels.earlierMessagesLoaded(loaded) : ''}</span>\n </div>;\n}\n",
65
65
  "type": "registry:component",
66
66
  "target": "components/pi-chat/history.tsx"
67
67
  },
@@ -97,7 +97,7 @@
97
97
  },
98
98
  {
99
99
  "path": "registry/pi-chat/pi-chat.tsx",
100
- "content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, 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 '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, messageStyle = 'default', className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}) } : undefined, [canFork, messageStyle]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!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))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
100
+ "content": "'use client';\n\nimport { useKeyboardInset } from '../utils/use-media';\n\nimport { useLayoutEffect, 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 '@hachej/boring-ui-kit/chat-text';\nimport { EmptyState } from './empty-state';\nimport type { Suggestion } from './empty-state';\nimport { ConversationHistory, TranscriptHistory } from './history';\nimport { TranscriptChunk } from './message';\nimport { markLeads } from './rows';\nimport type { Row } from './rows';\nimport { MessageQueue } from './queue';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from './notice';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from './session';\nimport { useChatDraft } from './use-chat-draft';\nimport { useChatPlacement } from './use-remote-chat';\nimport type { ChatPlacement } from './use-remote-chat';\nimport type { ChatFeatureProps, ChatPaging, FilesHandler } from './session';\nimport { ChatTextProvider, useChatText, useMergedText } from './labels';\nimport { chatTextFor } from './locale';\nimport type { ChatLocale } from './locale';\nimport type { ChatIcons, ChatLabels } from './labels';\nimport { cn } from '../utils/utils';\n\nexport type { ChatPaging, PiChatActions } from './session';\nexport type { ChatCard, Mode } from './rows';\nexport type { AnswerOutcome, ApprovalCardProps, ApprovalDecision, ChatArtifact, ChatArtifactCardProps, ChatArtifactsConfig, ChatCards, QuestionCardProps, SubagentRenderer } from './config';\nexport { ASK_USER_TOOL } from './config';\nexport type { ChatTextPart } from './message';\nexport type { MentionPickerProps, ModelEffortPickerProps, SlashPickerProps } from './config';\nexport type { PendingVoice, PendingVoiceBubbleProps, VoiceMessageButtonProps, VoiceMessageHandle } from './config';\nexport type { CameraSheetProps, VoiceRecorderHook, VoiceRecorderOptions } from './config';\nexport { voiceClock } from './config';\nexport { ChatTextProvider } from './labels';\nexport type { CommandMention, CommandMentions } from './config';\nexport type { Suggestion } from './empty-state';\nexport { ConversationList, relativeTime } from './history';\nexport { defaultChatIcons, defaultChatLabels } from './labels';\nexport type { ChatIcons, ChatLabels } from './labels';\nexport { frenchChatLabels } from './labels-fr';\nexport { chatTextFor } from './locale';\nexport type { ChatLocale } from './locale';\nexport { BlockActions } from '../button/actions';\nexport { useChatDraft } from './use-chat-draft';\nexport { useConversations } from './use-conversations';\nexport type { ChatController, ChatSnapshot } from '@hachej/boring-ui-kit/native-chat';\nexport { MessageText, VoiceBubble } from './message';\nexport { useTranscript } from './session';\nexport type { BlockAction, BlockIcon } from '../button/actions';\nexport type { AudioUrlRead, VoiceMessageConfig, AttachmentsConfig, ConversationItem, ConversationsConfig, EffortConfig, FileKind, MentionResult, MentionsConfig, ModelConfig, ModelRef, PasteToFile, PreviewRead, ReplyRef, SlashApi, SlashCommand, SlashConfig, SlashSkill, UploadResult } from './config';\n\nexport interface ChatTimeline {\n readonly count: number;\n readonly render: (context: { readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined; readonly targetLabel?: ChatFeatureProps['targetLabel'] }) => ReactNode;\n readonly afterSend?: () => void;\n}\n\nexport interface PiChatProps extends ChatFeatureProps {\n /** Optional host transcript. Absent: native rows, history and scroll behavior are unchanged. */\n readonly timeline?: ChatTimeline | undefined;\n /** Authorized host errors may provide their own message. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n readonly controller: ChatController;\n /** Where this chat is shown, reported with each submit (advisory, never authority). `docked` by default; a host that floats or tucks the same chat in a drawer says `floating`. */\n readonly placement?: ChatPlacement | undefined;\n /** Every word the chat shows (title, placeholder, buttons, notices, empty states): a partial object over `defaultChatLabels`. */\n /** The chat's language: `en` (default) or `fr`. `labels` override any of its words. */\n readonly locale?: ChatLocale | undefined;\n readonly labels?: Partial<ChatLabels> | undefined;\n /**\n * How a message looks. `default` (today): my message is a muted bubble on the right, the reply plain text. `messaging`: a messenger look\n * for a one-to-one conversation: my message a primary bubble on the right, the reply a muted bubble on the left, a tail on the first\n * bubble of each run and the time inside. Tool steps, traces, voice and cards are unchanged. Theme tokens only.\n */\n readonly messageStyle?: 'default' | 'messaging' | undefined;\n /** The chat's icons (send, stop, history, attach, …): any component taking `className`, over `defaultChatIcons`. */\n readonly icons?: Partial<ChatIcons> | undefined;\n /** The agent's avatar (an image or an icon) before the title in the header, and in the empty state instead of the agent icon. */\n readonly avatar?: ReactNode;\n /** Host actions in the header beside History: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `chat-action-<id>`, the menu `chat-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `false` lets the host supply one shared header around chat and viewers. Default true. */\n readonly header?: boolean;\n readonly className?: string;\n /** Pad the chat's bottom by how far it reaches below the visual viewport (the phone's soft keyboard). Default true; `false` for a host that already follows the keyboard itself, such as a bottom drawer whose lower part sits off screen by design. */\n readonly keyboardInset?: boolean;\n readonly decisions?: ReactNode;\n readonly controls?: ReactNode;\n /** Host controls at the end of the composer bar (for example a microphone), like AmbientChat's tools slot. */\n readonly tools?: ReactNode;\n /** Host controls at the left edge of the composer bar, before \"+\" (for example an app's own menu). Absent: nothing changes. */\n readonly composerStart?: ReactNode;\n /** With `voiceMessage`: the microphone takes Send's place while there is nothing to send, and Send comes back with the first character or file (WhatsApp's composer). Default false. */\n readonly voiceReplacesSend?: boolean;\n /** Hold-to-send one retained audio upload plus transcript. Omit to keep the existing composer unchanged. */\n readonly voiceMessage?: VoiceMessageConfig | undefined;\n /** Local voice rows owned by the shell's shared microphone; rendered with the native transcript, never the composer. */\n readonly pendingVoice?: readonly PendingVoice[] | undefined;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Rendered before the title in the header (for example a menu button on narrow screens). */\n readonly headerStart?: ReactNode;\n readonly onFiles?: FilesHandler;\n /** The phone camera's Take a photo entry for an `onFiles` chat (`CameraSheet` of `pi-chat-attachments`); with `attachments`, set `attachments.Camera` instead. */\n readonly camera?: AttachmentsConfig['Camera'];\n /** Shown instead of the default empty state before the first message. */\n readonly emptyState?: ReactNode;\n readonly suggestions?: readonly Suggestion[];\n /** Past conversations. The header's History button then opens a searchable list with the open one marked and a New action. Omit it and History pages through the earlier records of this conversation. */\n readonly conversations?: ConversationsConfig;\n /**\n * `false`: the host shows `conversations` itself (for example the `pi-app` sessions pane), so History pages through the earlier records of this\n * conversation while replies keep their Fork button (`conversations.fork`). Default `true`: History opens the list.\n */\n readonly historyList?: boolean;\n /** Show the header History button. Default true; paging remains available through the controller. */\n readonly showHistory?: boolean;\n /** Empty desktop composition. `centered` puts the composer beneath the greeting; `docked` keeps the former layout. Phones always dock it. */\n readonly emptyLayout?: 'centered' | 'docked';\n /** Two-row reference composer by default. Explicitly select `pill`, `stacked` or `inline` for the earlier layouts. */\n readonly composerLayout?: ComposerProps['layout'];\n /** A shell-owned capture uses the same recording-row replacement as native voice messages. */\n readonly composerRecording?: ReactNode;\n /** Shared host composer presentation over the normal kit session props. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n /** A conversation title, separate from the agent name, with optional click-to-rename. */\n readonly conversationTitle?: string | undefined;\n readonly onRenameTitle?: ((title: string) => void | Promise<void>) | undefined;\n /** Show the header connection badge. Default false; connection state still controls actions and failures stay visible. */\n readonly showConnectionStatus?: boolean;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n /** Send-time context without the annotate toggle/count UI. Uses the same native admission and restoration path. */\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\n /** Keeps the composer's half-typed message in localStorage under this key (a conversation id), across reloads and tabs; see `useChatDraft`. Omit it and nothing is stored. */\n readonly draftKey?: string | undefined;\n}\n\ntype TranscriptRow = { readonly key: string; readonly kind: 'native'; readonly row: Row }\n | { readonly key: string; readonly kind: 'voice'; readonly voice: PendingVoice };\n\n/** Borrows a concrete native controller; unmount releases presentation and uploads only. */\nexport function PiChat(props: PiChatProps) {\n const active = useRef(props.controller);\n useLayoutEffect(() => { active.current = props.controller; });\n // A new controller or a new voice recorder hook starts a fresh session, so the recorder's hooks keep their order.\n const recorder = props.voiceMessage?.useRecorder ?? noVoice;\n const [mount, setMount] = useState({ controller: props.controller, recorder, sequence: 0 });\n if (mount.controller !== props.controller || mount.recorder !== recorder) setMount({ controller: props.controller, recorder, sequence: mount.sequence + 1 });\n const text = useMergedText(props.labels, props.icons, chatTextFor(props.locale));\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><PiChatSession {...props} activeController={active} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\nfunction ConnectionBadge({ kind }: { readonly kind: string }) {\n const { labels } = useChatText();\n const live = kind === 'connected';\n return <Badge variant={live ? 'outline' : 'secondary'} data-testid=\"connection\" data-state={kind} role=\"status\">\n {live ? labels.live : kind === 'connecting' || kind === 'idle' ? labels.connecting : labels.reconnecting}\n </Badge>;\n}\n\n/** No voice message: without `voiceMessage.useRecorder` (from `pi-chat-voice`) the chat records nothing. */\nconst silent: VoiceMessageHandle = { canStart: false, phase: 'idle', seconds: 0, captureSequence: 0, problem: undefined, pending: [], start: () => {}, finish: () => {}, cancel: () => {} };\nconst noVoice: VoiceRecorderHook = () => silent;\n/** A shell's pending voice row when no `voiceMessage.Bubble` is plugged in: nothing to draw. */\nfunction NoVoiceBubble(_: { readonly pending: PendingVoice | undefined }) { return null; }\n\nfunction PiChatSession({ controller, placement = 'docked', mode = 'expert', presentation, voiceReplay, targetLabel, messageStyle = 'default', className, keyboardInset = true, actions, avatar, headerActions, header = true, decisions, controls, tools, composerStart, voiceReplacesSend = false, voiceMessage, pendingVoice, belowComposer, composerChips, composerHeader, composerHasContent, onPickFiles, timeline, renderConnectionError, headerStart, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions,\n onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept = 'image/*', emptyState, suggestions, messageActions, replyFooter, slash, mentions, attachments, model, effort, artifacts, conversations, historyList = true, showHistory = true, showConnectionStatus = false, emptyLayout = 'centered', composerLayout = 'card', composerRecording, renderComposer, conversationTitle, onRenameTitle, feedback, sendContext, draftKey, onOpenFile, fileChanges, pasteToFile, activeController: active }: PiChatProps & { readonly activeController: { readonly current: ChatController } }) {\n const { labels, icons } = useChatText();\n useChatDraft(controller, draftKey);\n useChatPlacement(controller, placement);\n const { title } = labels;\n const [pickingConversation, setPickingConversation] = useState(false);\n const scrollToBottom = useRef<() => void>(() => {});\n const useRecorder = voiceMessage?.useRecorder ?? noVoice;\n // eslint-disable-next-line react-hooks/refs -- the recorder is the host's hook, picked by prop, so the compiler cannot see it calls admitted only after a recording is admitted, never during render.\n const voice = useRecorder({ controller, config: voiceMessage, attachments, labels, activeController: active, admitted: () => timeline ? timeline.afterSend?.() : scrollToBottom.current() });\n // A reply's Fork button calls the host's latest `fork`; the row context changes only when forking is switched on or off.\n const fork = useRef(conversations?.fork);\n useLayoutEffect(() => { fork.current = conversations?.fork; });\n const canFork = Boolean(conversations?.fork);\n const rowExtras = useMemo(() => canFork || messageStyle === 'messaging' ? { ...(canFork ? { onFork: (entryId: string) => { void fork.current?.(entryId).catch(() => {}); } } : {}), ...(messageStyle === 'messaging' ? { messageStyle } : {}) } : undefined, [canFork, messageStyle]);\n const session = useChatSession({ controller, activeController: active, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, onFiles, camera, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => timeline ? timeline.afterSend?.() : scrollToBottom.current(), feedback, sendContext, rowExtras, onOpenFile, fileChanges, pasteToFile, composerChips, composerHeader, composerHasContent, onPickFiles });\n const { state, derived, rows: nativeRows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const voiceRows = pendingVoice ?? voice.pending;\n const VoiceButton = voiceMessage?.Button, VoiceBubbleView = voiceMessage?.Bubble ?? NoVoiceBubble;\n const rows = useMemo(() => messageStyle === 'messaging' ? markLeads(nativeRows) : nativeRows, [nativeRows, messageStyle]);\n const transcriptRows: readonly TranscriptRow[] = [\n ...rows.map(row => ({ key: row.key, kind: 'native' as const, row })),\n ...voiceRows.map(voice => ({ key: `voice:${voice.id}`, kind: 'voice' as const, voice })),\n ];\n const pendingRows = voiceRows.map(item => <MessageScrollerItem key={`voice:${item.id}`} messageId={`voice:${item.id}`} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item} /></MessageScrollerItem>);\n const empty = (timeline ? timeline.count === 0 : session.empty) && voiceRows.length === 0;\n const conversationFiles = new Map(files.map(file => [file.id, file]));\n for (const item of voiceRows) if (item.path && !conversationFiles.has(`file:${item.path}`)) {\n const path = item.path, open = onOpenFile ?? mentions?.open;\n conversationFiles.set(`file:${path}`, { id: `file:${path}`, name: displayName(path), open: open ? () => open(path) : undefined });\n }\n const { pinned } = derived;\n const transcript = useTranscript(transcriptRows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick, hidden } = transcript, { scrollRef, isAtBottom } = stick;\n useLayoutEffect(() => { scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); }; });\n const visible = transcript.visible(transcriptRows);\n // Native rows come before pending voice rows, so the hidden ones are the oldest native rows.\n const chunks = useRowChunks(useMemo(() => rows.slice(Math.min(rows.length, hidden)), [rows, hidden]));\n const { barStart, barNote } = session.pickers();\n const connectionKind = state.connection.kind;\n const [renamingTitle, setRenamingTitle] = useState(false);\n const [titleError, setTitleError] = useState(false);\n const page = useRef<HTMLElement>(null);\n useKeyboardInset(page, keyboardInset);\n\n return <section ref={page} data-boring=\"pi-chat\" data-connection={connectionKind} data-empty={empty && !loading ? emptyLayout : undefined} aria-label={title} className={cn('pi-chat relative box-border flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background text-foreground ', keyboardInset && 'max-md:pb-(--composer-keyboard-inset)', empty && emptyLayout === 'centered' && 'md:justify-center', className)}>\n {header && <header className={cn(empty && emptyLayout === 'centered' && 'md:absolute md:inset-x-0 md:top-0', 'flex h-13 shrink-0 items-center gap-2 px-3 md:gap-3 md:px-4')}>\n {headerStart}\n {avatar && <Avatar data-testid=\"chat-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n {renamingTitle && onRenameTitle ? <form className=\"min-w-0 flex-1\" onSubmit={event => { event.preventDefault(); const value = new FormData(event.currentTarget).get('title')?.toString().trim(); if (value) { setTitleError(false); void Promise.resolve().then(() => onRenameTitle(value)).catch(() => setTitleError(true)); } setRenamingTitle(false); }}>\n <Input name=\"title\" aria-label={labels.conversationName} maxLength={200} autoFocus defaultValue={conversationTitle ?? title} onBlur={() => setRenamingTitle(false)} onKeyDown={event => { if (event.key === 'Escape') setRenamingTitle(false); }} className=\"w-full min-w-0\" />\n </form> : <h2 className=\"m-0 min-w-0 flex-1 truncate font-(family-name:--chat-display) text-sm font-medium tracking-tight max-md:text-center\">{onRenameTitle ? <Button variant=\"ghost\" className=\"max-w-full\" onClick={() => setRenamingTitle(true)}><span className=\"truncate\">{conversationTitle ?? title}</span></Button> : conversationTitle ?? title}</h2>}\n {showHistory && conversations && historyList && <Button variant=\"ghost\" size=\"sm\" data-testid=\"history-open\" aria-haspopup=\"dialog\" aria-expanded={pickingConversation} disabled={state.disposed}\n onClick={() => setPickingConversation(open => !open)}><icons.history data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.history}</Button>}\n <BlockActions actions={headerActions} testId=\"chat-action\" menuLabel={labels.moreActions} />\n {showConnectionStatus && <ConnectionBadge kind={connectionKind} />}\n {controls}\n </header>}\n\n {titleError && <p role=\"alert\" className=\"m-0 shrink-0 px-4 py-2 text-sm text-destructive\">{labels.actionFailed}</p>}\n {decisions && <><div data-boring=\"required-decisions\" className=\"shrink-0 px-4 py-2\">{decisions}</div><Separator /></>}\n {pinned.length > 0 && <><aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 py-2\">{pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside><Separator /></>}\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"shrink-0 px-4 pt-2\">\n <ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></div>}\n {pickingConversation && conversations && historyList && <ConversationHistory conversations={conversations} onClose={() => setPickingConversation(false)} />}\n\n <div data-testid=\"conversation-content\" className={cn('relative min-h-0 flex-1', empty && emptyLayout === 'centered' && 'md:min-h-60 md:flex-none')}>\n {timeline ? timeline.render({ focusComposer: () => textarea.current?.focus(), openFile: onOpenFile ?? mentions?.open, pendingVoice: pendingRows, paging, targetLabel }) : <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && (hidden > 0 || state.history.kind !== 'idle' || state.view?.entries.some(entry => entry.head !== undefined)) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} context={rowContext} />)}\n {visible.map(item => item.kind === 'voice' && <MessageScrollerItem key={item.key} messageId={item.key} data-local-voice=\"\" className=\"[content-visibility:visible]\"><VoiceBubbleView pending={item.voice} /></MessageScrollerItem>)}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!empty && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!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))] md:px-6\">\n <div className=\"mx-auto w-full max-w-[720px] space-y-2\">\n <div {...(session.natural ? {} : { 'aria-live': 'polite' as const })} className=\"space-y-2 empty:hidden\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n </div>\n <div>\n <ConversationFiles files={[...conversationFiles.values()]} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {voice.problem && <p role=\"alert\" data-testid=\"voice-message-problem\">{voice.problem}</p>}\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer,\n recording: composerRecording ?? (VoiceButton && (voice.phase === 'starting' || voice.phase === 'recording') ? <VoiceButton voice={voice} /> : undefined), layout: composerLayout,\n placeholder: labels.placeholder === 'Message the agent…' ? `Message ${title}…` : labels.placeholder, barStart, barNote, start: composerStart,\n ...(voiceMessage && VoiceButton && voiceReplacesSend ? { barEnd: tools, whenEmpty: <VoiceButton voice={voice} primary disabled={state.disposed || !connected || state.send.kind === 'unknown'} /> }\n : { barEnd: voiceMessage && VoiceButton ? <>{tools}<VoiceButton voice={voice} disabled={state.disposed || !connected || state.send.kind === 'unknown'} /></> : tools }),\n })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </footer>\n </section>;\n}\n",
101
101
  "type": "registry:component",
102
102
  "target": "components/pi-chat/pi-chat.tsx"
103
103
  },
@@ -8,9 +8,9 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/dock.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/dock.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -9,10 +9,10 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-app.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-ambient.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-app.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/pi-ambient.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.39/r/utils.json"
16
16
  ],
17
17
  "files": [
18
18
  {