@hachej/boring-ui-kit 0.4.24 → 0.4.26

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 (76) hide show
  1. package/README.md +26 -2
  2. package/bin/boring-ui-blocks.mjs +50 -13
  3. package/dist/chat-artifacts.d.ts +157 -0
  4. package/dist/chat-artifacts.js +274 -0
  5. package/dist/chat-react-controller.d.ts +25 -0
  6. package/dist/chat-react-controller.js +72 -0
  7. package/dist/chat-react-conversations.d.ts +40 -0
  8. package/dist/chat-react-conversations.js +68 -0
  9. package/dist/chat-react-draft.d.ts +11 -0
  10. package/dist/chat-react-draft.js +91 -0
  11. package/dist/chat-react-remote.d.ts +33 -0
  12. package/dist/chat-react-remote.js +72 -0
  13. package/dist/chat-react-transcript.d.ts +39 -0
  14. package/dist/chat-react-transcript.js +123 -0
  15. package/dist/chat-react.d.ts +60 -0
  16. package/dist/chat-react.js +94 -0
  17. package/dist/chat-text.d.ts +122 -0
  18. package/dist/chat-text.js +216 -0
  19. package/dist/chat-view.d.ts +2 -17
  20. package/dist/chat-view.js +1 -0
  21. package/dist/file-tree.js +5 -0
  22. package/dist/run-notifications.d.ts +42 -0
  23. package/dist/run-notifications.js +67 -0
  24. package/dist/shared-chat/index.d.ts +1 -1
  25. package/dist/shared-chat/index.js +5 -4
  26. package/dist/voice-capture.d.ts +12 -0
  27. package/dist/voice-capture.js +17 -2
  28. package/package.json +10 -2
  29. package/r/agent-target.json +5 -9
  30. package/r/app-shell.json +16 -25
  31. package/r/board-viewer.json +7 -11
  32. package/r/button.json +2 -9
  33. package/r/diagram-viewer.json +0 -5
  34. package/r/dock.json +2 -2
  35. package/r/documents.json +14 -19
  36. package/r/feedback-preview.json +1 -7
  37. package/r/feedback.json +12 -19
  38. package/r/file-tree.json +4 -14
  39. package/r/highlights.json +4 -9
  40. package/r/html-viewer.json +3 -3
  41. package/r/looks-viewer.json +2 -6
  42. package/r/maquette-viewer.json +2 -20
  43. package/r/markdown-editor.json +4 -7
  44. package/r/mockup-viewer.json +2 -5
  45. package/r/pi-ambient.json +11 -19
  46. package/r/pi-app-kinds.json +10 -10
  47. package/r/pi-app.json +16 -26
  48. package/r/pi-artifacts.json +3 -4
  49. package/r/pi-chat-approval.json +3 -5
  50. package/r/pi-chat-attachments.json +2 -4
  51. package/r/pi-chat-markdown.json +3 -5
  52. package/r/pi-chat-mentions.json +2 -3
  53. package/r/pi-chat-pickers.json +2 -4
  54. package/r/pi-chat-plugins.json +11 -11
  55. package/r/pi-chat-question.json +1 -7
  56. package/r/pi-chat-slash.json +3 -6
  57. package/r/pi-chat-subagent.json +3 -8
  58. package/r/pi-chat-voice.json +6 -9
  59. package/r/pi-chat.json +17 -45
  60. package/r/pi-workspace.json +4 -7
  61. package/r/placed-agent.json +4 -5
  62. package/r/provider-setup.json +2 -8
  63. package/r/record-viewer.json +0 -9
  64. package/r/registry.json +163 -395
  65. package/r/requests-board.json +2 -10
  66. package/r/shared-chat.json +9 -23
  67. package/r/task-list-viewer.json +3 -9
  68. package/r/version-history.json +3 -5
  69. package/r/viewer-code.json +2 -3
  70. package/r/viewer-frame.json +7 -15
  71. package/r/viewer-html.json +4 -5
  72. package/r/viewer-image.json +3 -4
  73. package/r/viewer-markdown.json +6 -8
  74. package/r/viewer-pdf.json +5 -7
  75. package/r/viewers.json +6 -6
  76. package/r/windows.json +2 -7
@@ -9,23 +9,18 @@
9
9
  "@earendil-works/pi-ai@~1.2.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat.json",
13
- "alert",
14
- "badge",
15
- "button",
16
- "item",
17
- "spinner"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat.json"
18
13
  ],
19
14
  "files": [
20
15
  {
21
16
  "path": "registry/pi-chat-subagent/run-card.tsx",
22
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CheckIcon, CircleAlertIcon, RefreshCwIcon } from 'lucide-react';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\n\nexport type RunStatus = 'queued' | 'running' | 'completed' | 'failed';\n/** A headless run as the host's `GET _agent/<name>/runs/<id>` returns it. */\nexport interface RunRecord {\n readonly id: string;\n readonly status: RunStatus;\n /** The structured result: shown as text (`renderText`) when it is text, as JSON otherwise. */\n readonly output?: unknown;\n /** The run's final text, shown when there is no `output`. */\n readonly text?: string | undefined;\n readonly error?: string | undefined;\n /** The agent's title. */\n readonly agent?: string | undefined;\n /** Epoch milliseconds or an ISO date. */\n readonly startedAt?: number | string | undefined;\n readonly finishedAt?: number | string | undefined;\n}\n\nconst STATUSES: readonly RunStatus[] = ['queued', 'running', 'completed', 'failed'];\nconst time = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;\nconst optionalText = (value: unknown): string | undefined => typeof value === 'string' && value ? value : undefined;\n\n/** Reads a run record defensively (it comes over the network); undefined when it has no id or no known status. */\nexport function parseRun(value: unknown): RunRecord | undefined {\n if (value === null || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n const { id, status } = record;\n if ((typeof id !== 'string' || !id) || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)) return undefined;\n const startedAt = time(record['startedAt']), finishedAt = time(record['finishedAt']);\n return { id, status: status as RunStatus, ...(record['output'] === undefined || record['output'] === null ? {} : { output: record['output'] }),\n ...(optionalText(record['text']) ? { text: record['text'] as string } : {}), ...(optionalText(record['error']) ? { error: record['error'] as string } : {}),\n ...(optionalText(record['agent']) ? { agent: record['agent'] as string } : {}), ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min`. */\nconst span = (ms: number): string => {\n const seconds = Math.max(0, Math.round(ms / 1000));\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\n\n/**\n * A headless run (`POST _agent/<name>/runs` → `GET runs/<id>`) as a card: the agent's title, a status badge, a spinner while it is queued or\n * running with the elapsed time, then the duration and the output (or text, drawn by `renderText`: plain text, or `markdownText` of `pi-chat-markdown`), or the error. The host fetches: give `run` (a\n * record it keeps current), and optionally `fetchRun(id, signal)` to have the card poll every `pollMs` until the run settles and offer\n * Refresh. URLs are the host's (relative to its page). Render it from `renderTool` or `renderEntry`, or anywhere in the page.\n */\nconst plainRunText = ({ text }: ChatTextPart): ReactNode => <div data-testid=\"message-text\" className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport function RunCard({ run: given, fetchRun, pollMs = 2000, now: clock = Date.now, renderText = plainRunText }: {\n readonly run: RunRecord;\n readonly fetchRun?: ((id: string, signal: AbortSignal) => Promise<unknown>) | undefined;\n readonly pollMs?: number;\n /** Draws the run's text. Default: plain text. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const [fetched, setFetched] = useState<RunRecord | undefined>();\n const [failed, setFailed] = useState(false);\n const run = fetched?.id === given.id ? fetched : given;\n const settled = run.status === 'completed' || run.status === 'failed';\n const [now, setNow] = useState(clock);\n const latest = useRef(fetchRun); latest.current = fetchRun;\n\n const refresh = (signal: AbortSignal) => latest.current?.(given.id, signal).then(value => {\n if (signal.aborted) return;\n const parsed = parseRun(value);\n setFailed(!parsed);\n if (parsed?.id === given.id) setFetched(parsed);\n }).catch(() => { if (!signal.aborted) setFailed(true); });\n const polling = Boolean(fetchRun) && !settled;\n useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresh(abort.signal);\n const timer = setInterval(() => { void refresh(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => {\n if (settled || run.startedAt === undefined) return;\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [settled, run.startedAt, clock]);\n\n const started = time(run.startedAt), finished = time(run.finishedAt);\n const timing = started === undefined ? undefined : finished !== undefined ? labels.runDuration(span(finished - started)) : settled ? undefined : labels.runElapsed(span(now - started));\n const body = run.output !== undefined ? (typeof run.output === 'string' ? renderText({ text: run.output, streaming: false }) : <CodeBlock code={JSON.stringify(run.output, null, 2)} language=\"json\" />)\n : run.text ? renderText({ text: run.text, streaming: false }) : undefined;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"run-card\" data-state={run.status} data-run-id={run.id} aria-label={run.agent ?? labels.run} aria-busy={!settled} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {settled ? run.status === 'completed' ? <CheckIcon aria-hidden=\"true\" /> : <CircleAlertIcon aria-hidden=\"true\" /> : <Spinner data-testid=\"run-spinner\" aria-label={labels.runStatus(run.status)} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none [overflow-wrap:anywhere]\">{run.agent ?? labels.run}</ItemTitle>\n <ItemDescription className=\"line-clamp-none flex flex-wrap items-center gap-2\">\n <Badge data-testid=\"run-status\" variant={run.status === 'failed' ? 'destructive' : run.status === 'completed' ? 'secondary' : 'outline'}>{labels.runStatus(run.status)}</Badge>\n {timing && <span data-testid=\"run-time\">{timing}</span>}\n </ItemDescription>\n </ItemContent>\n {fetchRun && <Button variant=\"ghost\" size=\"icon\" data-testid=\"run-refresh\" aria-label={labels.runRefresh} title={labels.runRefresh}\n onClick={() => { void refresh(new AbortController().signal); }}><RefreshCwIcon aria-hidden=\"true\" /></Button>}\n {(body || run.error || failed) && <ItemFooter className=\"flex-col items-stretch\">\n {body && <div data-testid=\"run-output\" className=\"min-w-0\">{body}</div>}\n {run.error && <Alert variant=\"destructive\" data-testid=\"run-error\"><AlertDescription>{run.error}</AlertDescription></Alert>}\n {failed && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.runNotLoaded}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
17
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { Alert, AlertDescription } from '@/components/ui/alert';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CheckIcon, CircleAlertIcon, RefreshCwIcon } from 'lucide-react';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatTextPart } from '../pi-chat/message';\n\nexport type RunStatus = 'queued' | 'running' | 'completed' | 'failed';\n/** A headless run as the host's `GET _agent/<name>/runs/<id>` returns it. */\nexport interface RunRecord {\n readonly id: string;\n readonly status: RunStatus;\n /** The structured result: shown as text (`renderText`) when it is text, as JSON otherwise. */\n readonly output?: unknown;\n /** The run's final text, shown when there is no `output`. */\n readonly text?: string | undefined;\n readonly error?: string | undefined;\n /** The agent's title. */\n readonly agent?: string | undefined;\n /** Epoch milliseconds or an ISO date. */\n readonly startedAt?: number | string | undefined;\n readonly finishedAt?: number | string | undefined;\n}\n\nconst STATUSES: readonly RunStatus[] = ['queued', 'running', 'completed', 'failed'];\nconst time = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : undefined;\nconst optionalText = (value: unknown): string | undefined => typeof value === 'string' && value ? value : undefined;\n\n/** Reads a run record defensively (it comes over the network); undefined when it has no id or no known status. */\nexport function parseRun(value: unknown): RunRecord | undefined {\n if (value === null || typeof value !== 'object') return undefined;\n const record = value as Record<string, unknown>;\n const { id, status } = record;\n if ((typeof id !== 'string' || !id) || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)) return undefined;\n const startedAt = time(record['startedAt']), finishedAt = time(record['finishedAt']);\n return { id, status: status as RunStatus, ...(record['output'] === undefined || record['output'] === null ? {} : { output: record['output'] }),\n ...(optionalText(record['text']) ? { text: record['text'] as string } : {}), ...(optionalText(record['error']) ? { error: record['error'] as string } : {}),\n ...(optionalText(record['agent']) ? { agent: record['agent'] as string } : {}), ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min`. */\nconst span = (ms: number): string => {\n const seconds = Math.max(0, Math.round(ms / 1000));\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\n\n/**\n * A headless run (`POST _agent/<name>/runs` → `GET runs/<id>`) as a card: the agent's title, a status badge, a spinner while it is queued or\n * running with the elapsed time, then the duration and the output (or text, drawn by `renderText`: plain text, or `markdownText` of `pi-chat-markdown`), or the error. The host fetches: give `run` (a\n * record it keeps current), and optionally `fetchRun(id, signal)` to have the card poll every `pollMs` until the run settles and offer\n * Refresh. URLs are the host's (relative to its page). Render it from `renderTool` or `renderEntry`, or anywhere in the page.\n */\nconst plainRunText = ({ text }: ChatTextPart): ReactNode => <div data-testid=\"message-text\" className=\"min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{text}</div>;\n\nexport function RunCard({ run: given, fetchRun, pollMs = 2000, now: clock = Date.now, renderText = plainRunText }: {\n readonly run: RunRecord;\n readonly fetchRun?: ((id: string, signal: AbortSignal) => Promise<unknown>) | undefined;\n readonly pollMs?: number;\n /** Draws the run's text. Default: plain text. */\n readonly renderText?: ((part: ChatTextPart) => ReactNode) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const [fetched, setFetched] = useState<RunRecord | undefined>();\n const [failed, setFailed] = useState(false);\n const run = fetched?.id === given.id ? fetched : given;\n const settled = run.status === 'completed' || run.status === 'failed';\n const [now, setNow] = useState(clock);\n const latest = useRef(fetchRun);\n useLayoutEffect(() => { latest.current = fetchRun; });\n\n const refresh = (signal: AbortSignal) => latest.current?.(given.id, signal).then(value => {\n if (signal.aborted) return;\n const parsed = parseRun(value);\n setFailed(!parsed);\n if (parsed?.id === given.id) setFetched(parsed);\n }).catch(() => { if (!signal.aborted) setFailed(true); });\n const polling = Boolean(fetchRun) && !settled;\n // The latest refresh, so polling restarts only when it starts, stops, or the run or interval changes.\n const refresher = useRef(refresh);\n useLayoutEffect(() => { refresher.current = refresh; });\n useEffect(() => {\n if (!polling) return;\n const abort = new AbortController();\n void refresher.current(abort.signal);\n const timer = setInterval(() => { void refresher.current(abort.signal); }, pollMs);\n return () => { abort.abort(); clearInterval(timer); };\n }, [polling, given.id, pollMs]);\n useEffect(() => {\n if (settled || run.startedAt === undefined) return;\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [settled, run.startedAt, clock]);\n\n const started = time(run.startedAt), finished = time(run.finishedAt);\n const timing = started === undefined ? undefined : finished !== undefined ? labels.runDuration(span(finished - started)) : settled ? undefined : labels.runElapsed(span(now - started));\n const body = run.output !== undefined ? (typeof run.output === 'string' ? renderText({ text: run.output, streaming: false }) : <CodeBlock code={JSON.stringify(run.output, null, 2)} language=\"json\" />)\n : run.text ? renderText({ text: run.text, streaming: false }) : undefined;\n\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"run-card\" data-state={run.status} data-run-id={run.id} aria-label={run.agent ?? labels.run} aria-busy={!settled} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {settled ? run.status === 'completed' ? <CheckIcon aria-hidden=\"true\" /> : <CircleAlertIcon aria-hidden=\"true\" /> : <Spinner data-testid=\"run-spinner\" aria-label={labels.runStatus(run.status)} />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none [overflow-wrap:anywhere]\">{run.agent ?? labels.run}</ItemTitle>\n <ItemDescription className=\"line-clamp-none flex flex-wrap items-center gap-2\">\n <Badge data-testid=\"run-status\" variant={run.status === 'failed' ? 'destructive' : run.status === 'completed' ? 'secondary' : 'outline'}>{labels.runStatus(run.status)}</Badge>\n {timing && <span data-testid=\"run-time\">{timing}</span>}\n </ItemDescription>\n </ItemContent>\n {fetchRun && <Button variant=\"ghost\" size=\"icon\" data-testid=\"run-refresh\" aria-label={labels.runRefresh} title={labels.runRefresh}\n onClick={() => { void refresh(new AbortController().signal); }}><RefreshCwIcon aria-hidden=\"true\" /></Button>}\n {(body || run.error || failed) && <ItemFooter className=\"flex-col items-stretch\">\n {body && <div data-testid=\"run-output\" className=\"min-w-0\">{body}</div>}\n {run.error && <Alert variant=\"destructive\" data-testid=\"run-error\"><AlertDescription>{run.error}</AlertDescription></Alert>}\n {failed && <p role=\"alert\" className=\"m-0 text-sm text-destructive\">{labels.runNotLoaded}</p>}\n </ItemFooter>}\n </Item>;\n}\n",
23
18
  "type": "registry:component",
24
19
  "target": "components/pi-chat-subagent/run-card.tsx"
25
20
  },
26
21
  {
27
22
  "path": "registry/pi-chat-subagent/subagent-card.tsx",
28
- "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, CheckIcon, CircleAlertIcon, CircleSlashIcon, ClockIcon, WrenchIcon } from 'lucide-react';\nimport { useChatText } from '../pi-chat/labels';\nimport type { SubagentRenderer } from '../pi-chat/config';\n\n/** `DELEGATE_DETAILS_KIND` of `@hachej/boring-agent/agents` (a contract test keeps them equal). */\nexport const DELEGATE_DETAILS_KIND = 'boring.delegate';\nexport type SubagentStatus = 'running' | 'done' | 'failed' | 'cancelled';\n/** A delegate call's details (`DelegateDetails` of `@hachej/boring-agent/agents`, version 1) as the card reads them. */\nexport interface SubagentDetails {\n readonly agent: string;\n /** The child conversation (Pi's conversation id). */\n readonly conversationId: number;\n readonly status: SubagentStatus;\n /** Epoch milliseconds. */\n readonly startedAt: number;\n readonly durationMs: number;\n readonly toolCalls: number;\n readonly tokens: { readonly input: number; readonly output: number };\n readonly error?: string;\n}\n\nconst STATUSES: readonly SubagentStatus[] = ['running', 'done', 'failed', 'cancelled'];\nconst count = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;\n\n/**\n * Reads a delegate call's details defensively (they come from the conversation): undefined unless they are version 1\n * `DelegateDetails`. With the call's `result`, a value still `running` is settled from it: Pi writes the result of a stopped call\n * from the last running details, so a settled `running` reads as `cancelled` (or `failed` with the result's text when it is not\n * an abort), with the duration up to the result.\n */\nexport function parseDelegateDetails(value: unknown, result?: Pick<ToolResultMessage, 'isError' | 'timestamp' | 'content'>): SubagentDetails | undefined {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (record['kind'] !== DELEGATE_DETAILS_KIND || record['version'] !== 1) return undefined;\n const { agent, conversationId, status } = record;\n const tokens = record['tokens'] !== null && typeof record['tokens'] === 'object' ? record['tokens'] as Record<string, unknown> : {};\n const startedAt = count(record['startedAt']), durationMs = count(record['durationMs']), toolCalls = count(record['toolCalls']);\n const input = count(tokens['input']), output = count(tokens['output']);\n if (typeof agent !== 'string' || count(conversationId) === undefined || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)\n || startedAt === undefined || durationMs === undefined || toolCalls === undefined || input === undefined || output === undefined) return undefined;\n const error = typeof record['error'] === 'string' && record['error'] ? record['error'] : undefined;\n const details: SubagentDetails = { agent, conversationId: conversationId as number, status: status as SubagentStatus, startedAt, durationMs, toolCalls, tokens: { input, output }, ...(error ? { error } : {}) };\n if (!result || details.status !== 'running') return details;\n const text = result.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n').trim();\n const ended = Math.max(details.durationMs, typeof result.timestamp === 'number' ? result.timestamp - details.startedAt : 0);\n if (!result.isError) return { ...details, status: 'done', durationMs: ended };\n return /\\babort/i.test(text) || !text ? { ...details, status: 'cancelled', durationMs: ended } : { ...details, status: 'failed', durationMs: ended, error: text };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min` (under a second: `0.4 s`). */\nconst span = (ms: number): string => {\n if (ms < 1000) return `${(Math.max(0, ms) / 1000).toFixed(1)} s`;\n const seconds = Math.round(ms / 1000);\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\nconst compact = (value: number): string => value < 1000 ? String(value) : value < 1_000_000 ? `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k` : `${(value / 1_000_000).toFixed(1)}M`;\n\n/**\n * A sub-agent (`delegate`) call as a card: the child agent, its status, the time it has run, its tool calls and tokens, and the\n * child conversation's id; the error when it failed. The chat renders it for any tool call whose details are `DelegateDetails`,\n * live while the child runs. `onOpen(conversationId)` makes the id a button that opens the child conversation.\n */\nexport function SubagentCard({ details, onOpen, now: clock = Date.now }: {\n readonly details: SubagentDetails;\n readonly onOpen?: ((conversationId: number) => void) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const running = details.status === 'running';\n const [now, setNow] = useState(clock);\n useEffect(() => {\n if (!running) return;\n setNow(clock());\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [running, clock]);\n const elapsed = running ? Math.max(details.durationMs, now - details.startedAt) : details.durationMs;\n const conversation = labels.subagentConversation(details.conversationId);\n const status = labels.subagentStatus(details.status);\n const tokens = labels.subagentTokens(details.tokens.input, details.tokens.output);\n const calls = labels.subagentToolCalls(details.toolCalls);\n const time = `${labels.subagentTime}: ${span(elapsed)}`;\n return <Item variant=\"outline\" size=\"xs\" render={<section />} data-testid=\"subagent-card\" data-state={details.status} data-conversation-id={details.conversationId}\n aria-label={`${details.agent}, ${status}`} aria-busy={running} className=\"my-1 text-start\">\n <ItemMedia variant=\"icon\" data-testid=\"subagent-status\" role=\"img\" aria-label={status} title={status}>\n <span className={details.status === 'failed' ? 'contents text-destructive' : details.status === 'done' ? 'contents' : 'contents text-muted-foreground'}>\n {running ? <Spinner data-testid=\"subagent-spinner\" aria-hidden=\"true\" />\n : details.status === 'done' ? <CheckIcon aria-hidden=\"true\" /> : details.status === 'failed' ? <CircleAlertIcon aria-hidden=\"true\" /> : <CircleSlashIcon aria-hidden=\"true\" />}\n </span>\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5\">\n <ItemTitle className=\"min-w-0 [overflow-wrap:anywhere]\">{details.agent}</ItemTitle>\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0\">\n <span data-testid=\"subagent-time\" className=\"inline-flex items-center gap-1\" title={time} aria-label={time}><ClockIcon aria-hidden=\"true\" />{span(elapsed)}</span>\n <span data-testid=\"subagent-tool-calls\" data-count={details.toolCalls} className=\"inline-flex items-center gap-1\" title={calls} aria-label={calls}><WrenchIcon aria-hidden=\"true\" />{details.toolCalls}</span>\n <span data-testid=\"subagent-tokens\" className=\"inline-flex items-center gap-0.5\" title={tokens} aria-label={tokens}>\n <ArrowUpIcon aria-hidden=\"true\" />{compact(details.tokens.input)}<ArrowDownIcon aria-hidden=\"true\" className=\"ms-1\" />{compact(details.tokens.output)}\n </span>\n </span>\n </div>\n </ItemContent>\n <ItemActions>\n {onOpen\n ? <Button variant=\"ghost\" size=\"xs\" data-testid=\"subagent-open\" title={conversation} aria-label={conversation} onClick={() => onOpen(details.conversationId)}>\n #{details.conversationId}<ArrowUpRightIcon aria-hidden=\"true\" data-icon=\"inline-end\" />\n </Button>\n : <span data-testid=\"subagent-conversation\" className=\"text-xs text-muted-foreground tabular-nums\" title={conversation}>#{details.conversationId}</span>}\n </ItemActions>\n {details.status === 'failed' && details.error && <ItemFooter>\n <p data-testid=\"subagent-error\" role=\"alert\" className=\"m-0 ps-6 text-xs text-destructive [overflow-wrap:anywhere]\">{details.error}</p>\n </ItemFooter>}\n </Item>;\n}\n\n/**\n * The sub-agent card as the chat's `cards.subagent`: a call whose details (live from its running slot, or settled from its result) are\n * `DelegateDetails` becomes a `SubagentCard`. Any other call stays an ordinary tool step.\n */\nexport const subagentCard: SubagentRenderer = (_call, details, result) => {\n const parsed = parseDelegateDetails(details, result);\n return parsed ? <SubagentCard details={parsed} /> : undefined;\n};\n",
23
+ "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport type { ToolResultMessage } from '@earendil-works/pi-ai';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Spinner } from '@/components/ui/spinner';\nimport { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, CheckIcon, CircleAlertIcon, CircleSlashIcon, ClockIcon, WrenchIcon } from 'lucide-react';\nimport { useChatText } from '../pi-chat/labels';\nimport type { SubagentRenderer } from '../pi-chat/config';\n\n/** `DELEGATE_DETAILS_KIND` of `@hachej/boring-agent/agents` (a contract test keeps them equal). */\nexport const DELEGATE_DETAILS_KIND = 'boring.delegate';\nexport type SubagentStatus = 'running' | 'done' | 'failed' | 'cancelled';\n/** A delegate call's details (`DelegateDetails` of `@hachej/boring-agent/agents`, version 1) as the card reads them. */\nexport interface SubagentDetails {\n readonly agent: string;\n /** The child conversation (Pi's conversation id). */\n readonly conversationId: number;\n readonly status: SubagentStatus;\n /** Epoch milliseconds. */\n readonly startedAt: number;\n readonly durationMs: number;\n readonly toolCalls: number;\n readonly tokens: { readonly input: number; readonly output: number };\n readonly error?: string;\n}\n\nconst STATUSES: readonly SubagentStatus[] = ['running', 'done', 'failed', 'cancelled'];\nconst count = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;\n\n/**\n * Reads a delegate call's details defensively (they come from the conversation): undefined unless they are version 1\n * `DelegateDetails`. With the call's `result`, a value still `running` is settled from it: Pi writes the result of a stopped call\n * from the last running details, so a settled `running` reads as `cancelled` (or `failed` with the result's text when it is not\n * an abort), with the duration up to the result.\n */\nexport function parseDelegateDetails(value: unknown, result?: Pick<ToolResultMessage, 'isError' | 'timestamp' | 'content'>): SubagentDetails | undefined {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;\n const record = value as Record<string, unknown>;\n if (record['kind'] !== DELEGATE_DETAILS_KIND || record['version'] !== 1) return undefined;\n const { agent, conversationId, status } = record;\n const tokens = record['tokens'] !== null && typeof record['tokens'] === 'object' ? record['tokens'] as Record<string, unknown> : {};\n const startedAt = count(record['startedAt']), durationMs = count(record['durationMs']), toolCalls = count(record['toolCalls']);\n const input = count(tokens['input']), output = count(tokens['output']);\n if (typeof agent !== 'string' || count(conversationId) === undefined || typeof status !== 'string' || !(STATUSES as readonly string[]).includes(status)\n || startedAt === undefined || durationMs === undefined || toolCalls === undefined || input === undefined || output === undefined) return undefined;\n const error = typeof record['error'] === 'string' && record['error'] ? record['error'] : undefined;\n const details: SubagentDetails = { agent, conversationId: conversationId as number, status: status as SubagentStatus, startedAt, durationMs, toolCalls, tokens: { input, output }, ...(error ? { error } : {}) };\n if (!result || details.status !== 'running') return details;\n const text = result.content.flatMap(part => part.type === 'text' ? [part.text] : []).join('\\n').trim();\n const ended = Math.max(details.durationMs, typeof result.timestamp === 'number' ? result.timestamp - details.startedAt : 0);\n if (!result.isError) return { ...details, status: 'done', durationMs: ended };\n return /\\babort/i.test(text) || !text ? { ...details, status: 'cancelled', durationMs: ended } : { ...details, status: 'failed', durationMs: ended, error: text };\n}\n\n/** `42 s`, `3 min 5 s`, `1 h 2 min` (under a second: `0.4 s`). */\nconst span = (ms: number): string => {\n if (ms < 1000) return `${(Math.max(0, ms) / 1000).toFixed(1)} s`;\n const seconds = Math.round(ms / 1000);\n if (seconds < 60) return `${seconds} s`;\n const minutes = Math.floor(seconds / 60);\n if (minutes < 60) return `${minutes} min${seconds % 60 ? ` ${seconds % 60} s` : ''}`;\n return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;\n};\nconst compact = (value: number): string => value < 1000 ? String(value) : value < 1_000_000 ? `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k` : `${(value / 1_000_000).toFixed(1)}M`;\n\n/**\n * A sub-agent (`delegate`) call as a card: the child agent, its status, the time it has run, its tool calls and tokens, and the\n * child conversation's id; the error when it failed. The chat renders it for any tool call whose details are `DelegateDetails`,\n * live while the child runs. `onOpen(conversationId)` makes the id a button that opens the child conversation.\n */\nexport function SubagentCard({ details, onOpen, now: clock = Date.now }: {\n readonly details: SubagentDetails;\n readonly onOpen?: ((conversationId: number) => void) | undefined;\n /** Test seam: the clock of the elapsed time. */\n readonly now?: () => number;\n}) {\n const { labels } = useChatText();\n const running = details.status === 'running';\n const [now, setNow] = useState(clock);\n useEffect(() => {\n if (!running) return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a run that starts (again) reads the clock at once, not a second later\n setNow(clock());\n const timer = setInterval(() => setNow(clock()), 1000);\n return () => clearInterval(timer);\n }, [running, clock]);\n const elapsed = running ? Math.max(details.durationMs, now - details.startedAt) : details.durationMs;\n const conversation = labels.subagentConversation(details.conversationId);\n const status = labels.subagentStatus(details.status);\n const tokens = labels.subagentTokens(details.tokens.input, details.tokens.output);\n const calls = labels.subagentToolCalls(details.toolCalls);\n const time = `${labels.subagentTime}: ${span(elapsed)}`;\n return <Item variant=\"outline\" size=\"xs\" render={<section />} data-testid=\"subagent-card\" data-state={details.status} data-conversation-id={details.conversationId}\n aria-label={`${details.agent}, ${status}`} aria-busy={running} className=\"my-1 text-start\">\n <ItemMedia variant=\"icon\" data-testid=\"subagent-status\" role=\"img\" aria-label={status} title={status}>\n <span className={details.status === 'failed' ? 'contents text-destructive' : details.status === 'done' ? 'contents' : 'contents text-muted-foreground'}>\n {running ? <Spinner data-testid=\"subagent-spinner\" aria-hidden=\"true\" />\n : details.status === 'done' ? <CheckIcon aria-hidden=\"true\" /> : details.status === 'failed' ? <CircleAlertIcon aria-hidden=\"true\" /> : <CircleSlashIcon aria-hidden=\"true\" />}\n </span>\n </ItemMedia>\n <ItemContent className=\"min-w-0\">\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5\">\n <ItemTitle className=\"min-w-0 [overflow-wrap:anywhere]\">{details.agent}</ItemTitle>\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground tabular-nums [&_svg]:size-3.5 [&_svg]:shrink-0\">\n <span data-testid=\"subagent-time\" className=\"inline-flex items-center gap-1\" title={time} aria-label={time}><ClockIcon aria-hidden=\"true\" />{span(elapsed)}</span>\n <span data-testid=\"subagent-tool-calls\" data-count={details.toolCalls} className=\"inline-flex items-center gap-1\" title={calls} aria-label={calls}><WrenchIcon aria-hidden=\"true\" />{details.toolCalls}</span>\n <span data-testid=\"subagent-tokens\" className=\"inline-flex items-center gap-0.5\" title={tokens} aria-label={tokens}>\n <ArrowUpIcon aria-hidden=\"true\" />{compact(details.tokens.input)}<ArrowDownIcon aria-hidden=\"true\" className=\"ms-1\" />{compact(details.tokens.output)}\n </span>\n </span>\n </div>\n </ItemContent>\n <ItemActions>\n {onOpen\n ? <Button variant=\"ghost\" size=\"xs\" data-testid=\"subagent-open\" title={conversation} aria-label={conversation} onClick={() => onOpen(details.conversationId)}>\n #{details.conversationId}<ArrowUpRightIcon aria-hidden=\"true\" data-icon=\"inline-end\" />\n </Button>\n : <span data-testid=\"subagent-conversation\" className=\"text-xs text-muted-foreground tabular-nums\" title={conversation}>#{details.conversationId}</span>}\n </ItemActions>\n {details.status === 'failed' && details.error && <ItemFooter>\n <p data-testid=\"subagent-error\" role=\"alert\" className=\"m-0 ps-6 text-xs text-destructive [overflow-wrap:anywhere]\">{details.error}</p>\n </ItemFooter>}\n </Item>;\n}\n\n/**\n * The sub-agent card as the chat's `cards.subagent`: a call whose details (live from its running slot, or settled from its result) are\n * `DelegateDetails` becomes a `SubagentCard`. Any other call stays an ordinary tool step.\n */\nexport const subagentCard: SubagentRenderer = (_call, details, result) => {\n const parsed = parseDelegateDetails(details, result);\n return parsed ? <SubagentCard details={parsed} /> : undefined;\n};\n",
29
24
  "type": "registry:component",
30
25
  "target": "components/pi-chat-subagent/subagent-card.tsx"
31
26
  }
@@ -4,33 +4,30 @@
4
4
  "title": "Pi chat voice messages",
5
5
  "description": "Hold-to-record voice messages: the recorder hook over the kit's voice capture, 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.24",
7
+ "@hachej/boring-ui-kit@^0.4.26",
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.24/r/pi-chat.json",
13
- "bubble",
14
- "button",
15
- "message",
16
- "spinner"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat.json"
17
14
  ],
18
15
  "files": [
19
16
  {
20
17
  "path": "registry/pi-chat-voice/use-hold-to-record.ts",
21
- "content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport type { KeyboardEvent, PointerEvent } from 'react';\n\nexport interface HoldToRecordActions {\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n\n/** The immediate microphone and delayed shell trigger share cancellation, but retain ownership of their own held gesture. */\nexport function useRecordingCancellation(cancel: () => void) {\n const latest = useRef(cancel); latest.current = cancel;\n useEffect(() => {\n const stop = () => latest.current();\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') stop(); };\n const hidden = () => { if (document.hidden) stop(); };\n window.addEventListener('keydown', escape, true); window.addEventListener('blur', stop); document.addEventListener('visibilitychange', hidden);\n return () => {\n window.removeEventListener('keydown', escape, true); window.removeEventListener('blur', stop); document.removeEventListener('visibilitychange', hidden);\n stop();\n };\n }, []);\n}\n\n/** Gesture policy shared by the chat and shell. Pointer cancellation keeps audio; deliberate slide/Escape/lost visibility discards it. */\nexport function useHoldToRecord(actions: HoldToRecordActions, disabled = false) {\n const ref = useRef<HTMLButtonElement>(null);\n const latest = useRef({ actions, disabled }); latest.current = { actions, disabled };\n const held = useRef<number | string | null>(null);\n const cancel = () => { if (held.current === null) return; held.current = null; latest.current.actions.cancel(); };\n useRecordingCancellation(cancel);\n useEffect(() => {\n const node = ref.current;\n const keep = (event: Event) => { if (event.cancelable) event.preventDefault(); };\n node?.addEventListener('touchstart', keep, { passive: false });\n return () => node?.removeEventListener('touchstart', keep);\n }, []);\n const release = (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n held.current = null;\n if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n latest.current.actions.finish();\n };\n const key = (event: KeyboardEvent<HTMLButtonElement>, down: boolean) => {\n if (event.key !== ' ' && event.key !== 'Enter') return;\n event.preventDefault();\n if (down) {\n if (event.repeat || latest.current.disabled || held.current !== null) return;\n held.current = event.key; latest.current.actions.start();\n } else if (held.current === event.key) { held.current = null; latest.current.actions.finish(); }\n };\n return {\n ref,\n onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {\n event.preventDefault();\n if (event.button !== 0 || latest.current.disabled || held.current !== null) return;\n held.current = event.pointerId; event.currentTarget.setPointerCapture?.(event.pointerId); latest.current.actions.start();\n },\n onPointerUp: release,\n onPointerCancel: release,\n onPointerMove: (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) cancel();\n },\n onMouseDown: (event: { preventDefault: () => void }) => event.preventDefault(),\n onContextMenu: (event: { preventDefault: () => void }) => event.preventDefault(),\n onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => key(event, true),\n onKeyUp: (event: KeyboardEvent<HTMLButtonElement>) => key(event, false),\n onBlur: cancel,\n };\n}\n",
18
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef } from 'react';\nimport type { KeyboardEvent, PointerEvent } from 'react';\n\nexport interface HoldToRecordActions {\n readonly start: () => void;\n readonly finish: () => void;\n readonly cancel: () => void;\n}\n\n/** The immediate microphone and delayed shell trigger share cancellation, but retain ownership of their own held gesture. */\nexport function useRecordingCancellation(cancel: () => void) {\n const latest = useRef(cancel);\n useLayoutEffect(() => { latest.current = cancel; });\n useEffect(() => {\n const stop = () => latest.current();\n const escape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') stop(); };\n const hidden = () => { if (document.hidden) stop(); };\n window.addEventListener('keydown', escape, true); window.addEventListener('blur', stop); document.addEventListener('visibilitychange', hidden);\n return () => {\n window.removeEventListener('keydown', escape, true); window.removeEventListener('blur', stop); document.removeEventListener('visibilitychange', hidden);\n stop();\n };\n }, []);\n}\n\n/** Gesture policy shared by the chat and shell. Pointer cancellation keeps audio; deliberate slide/Escape/lost visibility discards it. */\nexport function useHoldToRecord(actions: HoldToRecordActions, disabled = false) {\n const ref = useRef<HTMLButtonElement>(null);\n const latest = useRef({ actions, disabled });\n useLayoutEffect(() => { latest.current = { actions, disabled }; });\n const held = useRef<number | string | null>(null);\n const cancel = () => { if (held.current === null) return; held.current = null; latest.current.actions.cancel(); };\n useRecordingCancellation(cancel);\n useEffect(() => {\n const node = ref.current;\n const keep = (event: Event) => { if (event.cancelable) event.preventDefault(); };\n node?.addEventListener('touchstart', keep, { passive: false });\n return () => node?.removeEventListener('touchstart', keep);\n }, []);\n const release = (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n held.current = null;\n if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n latest.current.actions.finish();\n };\n const key = (event: KeyboardEvent<HTMLButtonElement>, down: boolean) => {\n if (event.key !== ' ' && event.key !== 'Enter') return;\n event.preventDefault();\n if (down) {\n if (event.repeat || latest.current.disabled || held.current !== null) return;\n held.current = event.key; latest.current.actions.start();\n } else if (held.current === event.key) { held.current = null; latest.current.actions.finish(); }\n };\n return {\n ref,\n onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {\n event.preventDefault();\n if (event.button !== 0 || latest.current.disabled || held.current !== null) return;\n held.current = event.pointerId; event.currentTarget.setPointerCapture?.(event.pointerId); latest.current.actions.start();\n },\n onPointerUp: release,\n onPointerCancel: release,\n onPointerMove: (event: PointerEvent<HTMLButtonElement>) => {\n if (held.current !== event.pointerId) return;\n const box = event.currentTarget.getBoundingClientRect(), slack = 24;\n if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) cancel();\n },\n onMouseDown: (event: { preventDefault: () => void }) => event.preventDefault(),\n onContextMenu: (event: { preventDefault: () => void }) => event.preventDefault(),\n onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => key(event, true),\n onKeyUp: (event: KeyboardEvent<HTMLButtonElement>) => key(event, false),\n onBlur: cancel,\n };\n}\n",
22
19
  "type": "registry:hook",
23
20
  "target": "components/pi-chat-voice/use-hold-to-record.ts"
24
21
  },
25
22
  {
26
23
  "path": "registry/pi-chat-voice/use-voice-message.ts",
27
- "content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { createVoiceCapture, DEFAULT_MAX_VOICE_MS } from '@hachej/boring-ui-kit/voice-capture';\nimport type { VoiceCapture, VoiceRecording, VoiceRefusal } from '@hachej/boring-ui-kit/voice-capture';\nimport type { ChatController, ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { isVoicePath, mentionToken } from '../pi-chat/config';\nimport type { AttachmentsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoicePhase, VoiceRecorderOptions } from '../pi-chat/config';\nimport type { ChatLabels } from '../pi-chat/labels';\n\ninterface Attempt {\n readonly id: number;\n readonly controller: ChatController;\n readonly target: string;\n readonly transcribe: VoiceMessageConfig['transcribe'];\n readonly upload: AttachmentsConfig['upload'] | undefined;\n readonly abort: AbortController;\n readonly capture: VoiceCapture;\n readonly maxMs: number;\n phase: VoicePhase;\n seconds: number;\n file?: File;\n path?: string;\n transcript?: string;\n requestId?: string;\n}\nexport type { PendingVoice } from '../pi-chat/config';\nconst REFUSAL: Readonly<Record<VoiceRefusal, keyof Pick<ChatLabels, 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed'>>> = {\n denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',\n};\nconst targetOf = (controller: ChatController) => {\n const { identity, conversationId } = controller.getSnapshot();\n return JSON.stringify([identity.runtimeId, identity.scopeId, identity.principalId, conversationId]);\n};\nconst noSubscribe = () => () => {};\n\n/** Local admission preparation only. Native send/reconcile owns the submission; uploaded bytes stay in the host workspace. */\nexport function useVoiceMessage({ controller, config, attachments, labels, admitted, activeController }: VoiceRecorderOptions): VoiceMessageHandle {\n const selected = config ? controller : undefined;\n const readObservation = useCallback(() => {\n if (!selected) return '';\n const snapshot = selected.getSnapshot(), send = snapshot.send;\n return JSON.stringify([targetOf(selected), snapshot.disposed, send.kind, 'attempt' in send ? send.attempt.requestId : undefined]);\n }, [selected]);\n const observation = useSyncExternalStore(selected?.subscribe ?? noSubscribe, readObservation, readObservation);\n const latest = useRef({ controller, config, admitted, activeController }); latest.current = { controller, config, admitted, activeController };\n const mounted = useRef(false), attempts = useRef<Attempt[]>([]), captureSequence = useRef(0);\n const [, changed] = useState(0), [problem, setProblem] = useState<string>();\n const update = () => changed(value => value + 1);\n const valid = (item: Attempt) => mounted.current && attempts.current.includes(item) && latest.current.controller === item.controller && (!latest.current.activeController || latest.current.activeController.current === item.controller) && Boolean(latest.current.config) && !item.abort.signal.aborted && !item.controller.getSnapshot().disposed && targetOf(item.controller) === item.target;\n const remove = (item: Attempt) => {\n attempts.current = attempts.current.filter(current => current !== item);\n item.abort.abort(); item.capture.cancel();\n if (mounted.current) update();\n };\n const active = () => attempts.current.find(item => item.phase === 'starting' || item.phase === 'recording') ?? attempts.current.find(item => item.phase === 'processing' || item.phase === 'sending' || item.phase === 'unconfirmed');\n const cancel = () => { const item = active(); if (item) remove(item); };\n const clear = () => { for (const item of [...attempts.current]) remove(item); };\n const target = selected ? targetOf(selected) : undefined;\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; clear(); }; }, []);\n useEffect(() => { clear(); setProblem(undefined); }, [controller, target, Boolean(config)]);\n useEffect(() => {\n for (const item of [...attempts.current]) {\n if (!valid(item)) { remove(item); continue; }\n if (item.phase !== 'unconfirmed') continue;\n const snapshot = selected?.getSnapshot();\n if (snapshot?.send.kind === 'admitted' && 'attempt' in snapshot.send && snapshot.send.attempt.requestId === item.requestId) {\n remove(item); latest.current.admitted?.();\n } else if (snapshot?.send.kind === 'blocked') { item.phase = 'blocked'; update(); }\n }\n }, [selected, observation]);\n const recording = active(), phase = recording?.phase ?? 'idle';\n useEffect(() => {\n const item = recording;\n if (item?.phase !== 'recording') return;\n const started = performance.now();\n const timer = setInterval(() => { if (valid(item)) { item.seconds = Math.floor(Math.min(performance.now() - started, item.maxMs) / 1000); update(); } }, 250);\n return () => clearInterval(timer);\n }, [recording, phase]);\n\n const send = async (item: Attempt, audioOnly = false) => {\n if (!valid(item) || !item.path || item.phase === 'sending' || item.phase === 'unconfirmed') return;\n const transcript = audioOnly ? '' : item.transcript ?? '';\n const text = `${mentionToken(item.path)}${transcript ? ` ${transcript}` : ''}`;\n const before = item.controller.getSnapshot();\n if (before.send.kind === 'unknown') {\n // This recording was never submitted. Do not bind it to another message's uncertain request.\n const draft = item.controller.getSnapshot().draft.text;\n if (transcript) item.controller.setText(`${draft}${draft && !/\\s$/.test(draft) ? ' ' : ''}${transcript}`);\n item.phase = 'blocked'; update(); return;\n }\n item.phase = 'sending'; update();\n let submittedDraft: ChatDraft | undefined;\n try {\n const pending = item.controller.send('followUp', { text, restore: { text: transcript } });\n const after = item.controller.getSnapshot();\n // Native send synchronously publishes this immutable draft in validation or at its outbox position; equal text is not identity.\n submittedDraft = after.send.kind === 'validating' && (before.send.kind !== 'validating' || after.send.draft !== before.send.draft)\n ? after.send.draft : after.outbox[before.outbox.length];\n const result = await pending;\n if (!valid(item)) return;\n if (result) { remove(item); latest.current.admitted?.(); return; }\n } catch { if (!valid(item)) return; }\n const native = item.controller.getSnapshot().send;\n if (native.kind === 'admitted' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft) {\n remove(item); latest.current.admitted?.(); return;\n }\n const ownUnknown = native.kind === 'unknown' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft;\n item.phase = native.kind === 'blocked' || (native.kind === 'unknown' && 'attempt' in native && !ownUnknown) ? 'blocked' : 'unconfirmed';\n if (ownUnknown) item.requestId = native.attempt.requestId;\n update();\n };\n const process = async (item: Attempt) => {\n if (!valid(item) || !item.file) return;\n item.phase = 'processing'; update();\n // Cache each successful half independently: retrying transcription must not upload the same recording again.\n await Promise.all([\n item.path ? undefined : (async () => {\n try {\n const results = await item.upload?.([item.file!], item.abort.signal);\n const path = results?.length === 1 ? results[0]?.path : undefined;\n if (valid(item) && path && isVoicePath(path)) item.path = path;\n } catch { /* Failed uploads remain pending for explicit retry. */ }\n })(),\n item.transcript !== undefined ? undefined : (async () => {\n try { const text = (await item.transcribe(item.file!, item.abort.signal)).trim(); if (valid(item) && text) item.transcript = text; }\n catch { /* Never send automatically when transcription fails. */ }\n })(),\n ]);\n if (!valid(item)) return;\n if (!item.path) { item.phase = 'upload-failed'; update(); }\n else if (!item.transcript) { item.phase = 'transcribe-failed'; update(); }\n else await send(item);\n };\n const finish = () => {\n const item = active();\n if (!item || !valid(item)) return;\n if (item.phase === 'starting') { cancel(); return; }\n if (item.phase !== 'recording') return;\n item.phase = 'processing'; update();\n void (async () => {\n const recording: VoiceRecording | null = await item.capture.stop().catch(() => null);\n if (!valid(item)) return;\n if (!recording) { remove(item); setProblem(labels.voiceNothingHeard); update(); return; }\n item.seconds = Math.max(0, Math.round((recording.endedAt - recording.startedAt) / 1000));\n const type = recording.mimeType.split(';')[0]!, ext = type === 'audio/wav' ? 'wav' : type === 'audio/mp4' ? 'mp4' : type === 'audio/ogg' ? 'ogg' : 'webm';\n const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\\.(\\d+)Z$/, '-$1');\n item.file = new File([recording.audio], `voice-${stamp}.${ext}`, { type });\n await process(item);\n })();\n };\n const start = () => {\n if (!controller || !config || active() || controller.getSnapshot().disposed || controller.getSnapshot().send.kind === 'unknown') return;\n captureSequence.current += 1; setProblem(undefined);\n const maxMs = config.maxMs ?? DEFAULT_MAX_VOICE_MS;\n const item: Attempt = { id: captureSequence.current, maxMs: Number.isFinite(maxMs) && maxMs > 0 ? maxMs : Infinity, controller, transcribe: config.transcribe.bind(config), upload: config.upload?.bind(config) ?? attachments?.upload.bind(attachments), target: targetOf(controller), abort: new AbortController(), capture: createVoiceCapture({ ...(config.maxMs !== undefined ? { maxMs: config.maxMs } : {}) }), phase: 'starting', seconds: 0 };\n attempts.current = [...attempts.current, item]; update();\n void item.capture.start().then(result => {\n if (!valid(item)) { item.capture.cancel(); return; }\n if (result.kind === 'refused') { remove(item); setProblem(result.code ? labels[REFUSAL[result.code]] : result.reason); update(); return; }\n item.phase = 'recording'; update();\n });\n };\n const item = active();\n const pending: readonly PendingVoice[] = attempts.current.filter(item => item.phase !== 'starting' && item.phase !== 'recording').map(item => ({\n id: item.id, path: item.path, phase: item.phase, seconds: item.seconds,\n retry: () => { if (item.phase === 'upload-failed' || item.phase === 'transcribe-failed') void process(item); },\n audioOnly: item.phase === 'transcribe-failed' && item.path ? () => { if (item.phase === 'transcribe-failed') void send(item, true); } : undefined,\n discard: () => { if (valid(item)) remove(item); },\n }));\n return { canStart: Boolean(controller && config && !item && !controller.getSnapshot().disposed && controller.getSnapshot().send.kind !== 'unknown'), phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0, captureSequence: captureSequence.current, problem, pending, start, finish, cancel };\n}\n",
24
+ "content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport { createVoiceCapture } from '@hachej/boring-ui-kit/voice-capture';\nimport type { VoiceCapture, VoiceRecording, VoiceRefusal } from '@hachej/boring-ui-kit/voice-capture';\nimport type { ChatController, ChatDraft } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft, isVoicePath, mentionedPaths, mentionToken, withoutMentions } from '@hachej/boring-ui-kit/chat-text';\nimport type { AttachmentsConfig, PendingVoice, VoiceMessageConfig, VoiceMessageHandle, VoicePhase, VoiceRecorderOptions } from '../pi-chat/config';\nimport { useRecordingCancellation } from './use-hold-to-record';\nimport type { ChatLabels } from '../pi-chat/labels';\n\ninterface Attempt {\n readonly id: number;\n readonly controller: ChatController;\n readonly target: string;\n readonly transcribe: VoiceMessageConfig['transcribe'];\n readonly upload: AttachmentsConfig['upload'] | undefined;\n readonly abort: AbortController;\n readonly capture: VoiceCapture;\n mentions: readonly string[];\n phase: VoicePhase;\n seconds: number;\n levels: number[];\n paused: boolean;\n canPause: boolean;\n file?: File;\n path?: string;\n transcript?: string;\n requestId?: string;\n}\nexport type { PendingVoice } from '../pi-chat/config';\nconst REFUSAL: Readonly<Record<VoiceRefusal, keyof Pick<ChatLabels, 'voiceDenied' | 'voiceNoMicrophone' | 'voiceBusy' | 'voiceInsecure' | 'voiceUnsupported' | 'voiceStartFailed'>>> = {\n denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',\n};\nconst targetOf = (controller: ChatController) => {\n const { identity, conversationId } = controller.getSnapshot();\n return JSON.stringify([identity.runtimeId, identity.scopeId, identity.principalId, conversationId]);\n};\nconst noSubscribe = () => () => {};\n\n/** Local admission preparation only. Native send/reconcile owns the submission; uploaded bytes stay in the host workspace. */\nexport function useVoiceMessage({ controller, config, attachments, labels, admitted, activeController }: VoiceRecorderOptions): VoiceMessageHandle {\n const selected = config ? controller : undefined;\n const readObservation = useCallback(() => {\n if (!selected) return '';\n const snapshot = selected.getSnapshot(), send = snapshot.send;\n return JSON.stringify([targetOf(selected), snapshot.disposed, send.kind, 'attempt' in send ? send.attempt.requestId : undefined]);\n }, [selected]);\n const observation = useSyncExternalStore(selected?.subscribe ?? noSubscribe, readObservation, readObservation);\n // eslint-disable-next-line react-hooks/refs -- async capture and send callbacks check the props of the latest render; assigned in render so they never see the previous controller\n const latest = useRef({ controller, config, admitted, activeController }); latest.current = { controller, config, admitted, activeController };\n const mounted = useRef(false), attempts = useRef<Attempt[]>([]), captureSequence = useRef(0);\n const [, changed] = useState(0), [problem, setProblem] = useState<string>();\n const update = () => changed(value => value + 1);\n const valid = (item: Attempt) => mounted.current && attempts.current.includes(item) && latest.current.controller === item.controller && (!latest.current.activeController || latest.current.activeController.current === item.controller) && Boolean(latest.current.config) && !item.abort.signal.aborted && !item.controller.getSnapshot().disposed && targetOf(item.controller) === item.target;\n const remove = (item: Attempt) => {\n attempts.current = attempts.current.filter(current => current !== item);\n item.abort.abort(); item.capture.cancel();\n if (mounted.current) update();\n };\n const active = () => attempts.current.find(item => item.phase === 'starting' || item.phase === 'recording') ?? attempts.current.find(item => item.phase === 'processing' || item.phase === 'sending' || item.phase === 'unconfirmed');\n const cancel = () => { const item = active(); if (item) remove(item); };\n useRecordingCancellation(() => { const item = active(); if (item?.phase === 'starting' || item?.phase === 'recording') cancel(); });\n const clear = () => { for (const item of [...attempts.current]) remove(item); };\n const target = selected ? targetOf(selected) : undefined;\n // eslint-disable-next-line react-hooks/exhaustive-deps -- mount/unmount only: unmounting drops whatever attempts exist then\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; clear(); }; }, []);\n const configured = Boolean(config);\n // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/set-state-in-effect -- another controller, conversation or config drops the attempts and the problem, once per change\n useEffect(() => { clear(); setProblem(undefined); }, [controller, target, configured]);\n useEffect(() => {\n for (const item of [...attempts.current]) {\n if (!valid(item)) { remove(item); continue; }\n if (item.phase !== 'unconfirmed') continue;\n const snapshot = selected?.getSnapshot();\n if (snapshot?.send.kind === 'admitted' && 'attempt' in snapshot.send && snapshot.send.attempt.requestId === item.requestId) {\n remove(item); latest.current.admitted?.();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the chat's send state settles an unconfirmed attempt; update() republishes the attempt ref\n } else if (snapshot?.send.kind === 'blocked') { item.phase = 'blocked'; update(); }\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the chat's observation changes; remove/update are per-render closures over refs\n }, [selected, observation]);\n // eslint-disable-next-line react-hooks/refs -- the attempts live in a ref that every change pairs with update(); render reads the list as of that update\n const recording = active(), phase = recording?.phase ?? 'idle';\n useEffect(() => {\n const item = recording;\n if (item?.phase !== 'recording') return;\n let elapsedMs = 0;\n const timer = setInterval(() => {\n if (!valid(item)) return;\n const progress = item.capture.progress();\n if (!progress) return;\n item.seconds = Math.floor(progress.elapsedMs / 1000);\n item.canPause = progress.canPause; item.paused = progress.paused;\n if (progress.level !== undefined && !progress.paused && progress.elapsedMs > elapsedMs) item.levels = [...item.levels.slice(-31), progress.level];\n elapsedMs = progress.elapsedMs;\n update();\n }, 100);\n return () => clearInterval(timer);\n }, [recording, phase]);\n\n const send = async (item: Attempt, audioOnly = false) => {\n if (!valid(item) || !item.path || item.phase === 'sending' || item.phase === 'unconfirmed') return;\n const transcript = audioOnly ? '' : item.transcript ?? '';\n const text = [...item.mentions.map(mentionToken), mentionToken(item.path), transcript].filter(Boolean).join(' ');\n const before = item.controller.getSnapshot();\n if (before.send.kind === 'unknown') {\n // This recording was never submitted. Do not bind it to another message's uncertain request.\n const draft = item.controller.getSnapshot().draft.text;\n if (transcript) item.controller.setText(appendToDraft(draft, transcript));\n item.phase = 'blocked'; update(); return;\n }\n item.phase = 'sending'; update();\n let submittedDraft: ChatDraft | undefined;\n try {\n const pending = item.controller.send('followUp', { text, restore: { text: transcript } });\n const after = item.controller.getSnapshot();\n // Native send synchronously publishes this immutable draft in validation or at its outbox position; equal text is not identity.\n submittedDraft = after.send.kind === 'validating' && (before.send.kind !== 'validating' || after.send.draft !== before.send.draft)\n ? after.send.draft : after.outbox[before.outbox.length];\n const result = await pending;\n if (!valid(item)) return;\n if (result) { remove(item); latest.current.admitted?.(); return; }\n } catch {\n if (!valid(item)) return;\n const draft = item.controller.getSnapshot().draft.text;\n if (transcript) item.controller.setText(appendToDraft(draft, transcript));\n }\n const native = item.controller.getSnapshot().send;\n if (native.kind === 'admitted' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft) {\n remove(item); latest.current.admitted?.(); return;\n }\n const ownUnknown = native.kind === 'unknown' && 'attempt' in native && submittedDraft !== undefined && native.attempt.draft === submittedDraft;\n item.phase = native.kind === 'blocked' || (native.kind === 'unknown' && 'attempt' in native && !ownUnknown) ? 'blocked' : 'unconfirmed';\n if (ownUnknown) item.requestId = native.attempt.requestId;\n update();\n };\n const process = async (item: Attempt) => {\n if (!valid(item) || !item.file) return;\n item.phase = 'processing'; update();\n // Cache each successful half independently: retrying transcription must not upload the same recording again.\n await Promise.all([\n item.path ? undefined : (async () => {\n try {\n const results = await item.upload?.([item.file!], item.abort.signal);\n const path = results?.length === 1 ? results[0]?.path : undefined;\n if (valid(item) && path && isVoicePath(path)) item.path = path;\n } catch { /* Failed uploads remain pending for explicit retry. */ }\n })(),\n item.transcript !== undefined ? undefined : (async () => {\n try { const text = (await item.transcribe(item.file!, item.abort.signal)).trim(); if (valid(item) && text) item.transcript = text; }\n catch { /* Never send automatically when transcription fails. */ }\n })(),\n ]);\n if (!valid(item)) return;\n if (!item.path) { item.phase = 'upload-failed'; update(); }\n else if (!item.transcript) { item.phase = 'transcribe-failed'; update(); }\n else await send(item);\n };\n const finish = () => {\n const item = active();\n if (!item || !valid(item)) return;\n if (item.phase === 'starting') { cancel(); return; }\n if (item.phase !== 'recording') return;\n const draft = item.controller.getSnapshot().draft.text;\n item.mentions = mentionedPaths(draft);\n item.controller.setText(withoutMentions(draft));\n item.phase = 'processing'; update();\n void (async () => {\n const recording: VoiceRecording | null = await item.capture.stop().catch(() => null);\n if (!valid(item)) return;\n if (!recording) { remove(item); setProblem(labels.voiceNothingHeard); update(); return; }\n item.seconds = Math.max(0, Math.round((recording.endedAt - recording.startedAt) / 1000));\n const type = recording.mimeType.split(';')[0]!, ext = type === 'audio/wav' ? 'wav' : type === 'audio/mp4' ? 'mp4' : type === 'audio/ogg' ? 'ogg' : 'webm';\n const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\\.(\\d+)Z$/, '-$1');\n item.file = new File([recording.audio], `voice-${stamp}.${ext}`, { type });\n await process(item);\n })();\n };\n const start = () => {\n if (!controller || !config || active() || controller.getSnapshot().disposed || controller.getSnapshot().send.kind === 'unknown') return;\n captureSequence.current += 1; setProblem(undefined);\n const item: Attempt = { id: captureSequence.current, controller, mentions: [], transcribe: config.transcribe.bind(config), upload: config.upload?.bind(config) ?? attachments?.upload.bind(attachments), target: targetOf(controller), abort: new AbortController(), capture: createVoiceCapture({ ...(config.maxMs !== undefined ? { maxMs: config.maxMs } : {}) }), phase: 'starting', seconds: 0, levels: [], paused: false, canPause: false };\n attempts.current = [...attempts.current, item]; update();\n void item.capture.start().then(result => {\n if (!valid(item)) { item.capture.cancel(); return; }\n if (result.kind === 'refused') { remove(item); setProblem(result.code ? labels[REFUSAL[result.code]] : result.reason); update(); return; }\n item.phase = 'recording'; update();\n });\n };\n // eslint-disable-next-line react-hooks/refs -- the attempts live in a ref that every change pairs with update(); render reads the list as of that update\n const item = active();\n // eslint-disable-next-line react-hooks/refs -- the attempts live in a ref that every change pairs with update(); render reads the list as of that update\n const pending: readonly PendingVoice[] = attempts.current.filter(item => item.phase !== 'starting' && item.phase !== 'recording').map(item => ({\n id: item.id, path: item.path, phase: item.phase, seconds: item.seconds,\n retry: () => { if (item.phase === 'upload-failed' || item.phase === 'transcribe-failed') void process(item); },\n audioOnly: item.phase === 'transcribe-failed' && item.path ? () => { if (item.phase === 'transcribe-failed') void send(item, true); } : undefined,\n discard: () => { if (valid(item)) remove(item); },\n }));\n // eslint-disable-next-line react-hooks/refs -- the capture sequence is a ref bumped with update(); render reads it as of that update\n return { canStart: Boolean(controller && config && !item && !controller.getSnapshot().disposed && controller.getSnapshot().send.kind !== 'unknown'), phase: item?.phase ?? 'idle', seconds: item?.seconds ?? 0, levels: item?.levels ?? [], paused: item?.paused ?? false, pause: item?.phase === 'recording' && item.canPause ? () => { if (item.capture.pause()) { item.paused = true; update(); } } : undefined, resume: item?.phase === 'recording' && item.canPause ? () => { if (item.capture.resume()) { item.paused = false; update(); } } : undefined, captureSequence: captureSequence.current, problem, pending, start, finish, cancel };\n}\n",
28
25
  "type": "registry:hook",
29
26
  "target": "components/pi-chat-voice/use-voice-message.ts"
30
27
  },
31
28
  {
32
29
  "path": "registry/pi-chat-voice/voice-message.tsx",
33
- "content": "'use client';\n\nimport { MicIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { useChatText } from '../pi-chat/labels';\nimport { voiceClock } from '../pi-chat/config';\nimport type { PendingVoiceBubbleProps, VoiceMessageButtonProps } from '../pi-chat/config';\nimport { useHoldToRecord } from './use-hold-to-record';\nimport { useVoiceMessage } from './use-voice-message';\n\nexport { voiceClock } from '../pi-chat/config';\n\nexport function VoiceMessageButton({ voice, disabled, primary = false }: VoiceMessageButtonProps) {\n const { labels } = useChatText();\n const active = voice.phase === 'starting' || voice.phase === 'recording';\n const blocked = !active && (disabled || voice.phase !== 'idle');\n const hold = useHoldToRecord(voice, Boolean(blocked));\n return <div className=\"flex items-center gap-2\"><Button {...hold} variant={primary ? 'default' : 'ghost'} size=\"icon\" className=\"touch-none\" data-testid=\"voice-message-record\" data-state={voice.phase} disabled={blocked} aria-label={labels.voiceHold} title={labels.voiceHold} aria-pressed={voice.phase === 'recording'}>\n <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>{(voice.phase === 'starting' || voice.phase === 'recording') && <span role=\"status\" data-testid=\"voice-recording\">{labels.voiceRecording} {voiceClock(voice.seconds)}</span>}</div>;\n}\n\nexport function PendingVoiceBubble({ pending }: PendingVoiceBubbleProps) {\n const { labels } = useChatText();\n if (!pending) return null;\n const failed = pending.phase === 'transcribe-failed' || pending.phase === 'upload-failed';\n const blocked = pending.phase === 'blocked' || pending.phase === 'unconfirmed';\n return <Message align=\"end\" data-testid=\"voice-pending\" data-voice-id={pending.id} data-state={pending.phase}><MessageContent><Bubble variant=\"muted\" align=\"end\" className={failed || blocked ? 'w-full max-w-full' : undefined}><BubbleContent>\n <div role={failed ? 'alert' : 'status'} className=\"flex items-center gap-2\">\n {!failed && !blocked && <Spinner aria-hidden=\"true\" />}\n {Number.isFinite(pending.seconds) && pending.seconds > 0 && <span data-testid=\"voice-pending-duration\">{voiceClock(pending.seconds)}</span>}\n <span>{pending.phase === 'upload-failed' ? labels.uploadFailedShort : pending.phase === 'transcribe-failed' ? labels.voiceTranscribeFailed : pending.phase === 'blocked' ? labels.voiceBlocked : pending.phase === 'unconfirmed' ? labels.voiceUnconfirmed : labels.voicePreparing}</span>\n </div>\n {(failed || blocked) && <div className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2\">\n {failed && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-retry\" onClick={pending.retry}>{labels.retry}</Button>}\n {pending.audioOnly && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-audio-only\" onClick={pending.audioOnly}>{labels.voiceAudioOnly}</Button>}\n {pending.phase !== 'unconfirmed' && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"ghost\" data-testid=\"voice-discard\" onClick={pending.discard}>{labels.voiceDiscard}</Button>}\n </div>}\n </BubbleContent></Bubble></MessageContent></Message>;\n}\n\n/** Hold-to-record voice messages for the chat: `voiceMessage={{ ...config, ...voiceMessagePlugin }}`. */\nexport const voiceMessagePlugin = { useRecorder: useVoiceMessage, Button: VoiceMessageButton, Bubble: PendingVoiceBubble } as const;\n",
30
+ "content": "'use client';\n\nimport { CircleIcon, MicIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { useChatText } from '../pi-chat/labels';\nimport { voiceClock } from '../pi-chat/config';\nimport type { PendingVoiceBubbleProps, VoiceMessageButtonProps } from '../pi-chat/config';\nimport { useVoiceMessage } from './use-voice-message';\n\nexport { voiceClock } from '../pi-chat/config';\n\n/** The same recording row for native chat and a shell-owned capture. It replaces the editable composer controls. */\nexport function RecordingBar({ voice, testId = 'voice-message-record' }: Pick<VoiceMessageButtonProps, 'voice' | 'testId'>) {\n const { labels, icons } = useChatText();\n return <div data-testid=\"voice-recording-bar\" data-state={voice.paused ? 'paused' : voice.phase} className=\"flex w-full min-w-0 items-center gap-2\">\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={voice.cancel} className=\"min-h-11 min-w-11 shrink-0\"><Trash2Icon aria-hidden=\"true\" /></Button>\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <CircleIcon className=\"size-3 shrink-0 fill-current text-destructive\" aria-hidden=\"true\" />\n <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" data-testid=\"voice-recording\" className=\"shrink-0 tabular-nums\"><span className=\"sr-only\">{voice.paused ? labels.voicePaused : labels.voiceRecording} </span>{voiceClock(voice.seconds)}</span>\n {!!voice.levels?.length && <div data-testid=\"voice-waveform\" aria-hidden=\"true\" className=\"flex h-8 min-w-0 flex-1 items-center justify-end gap-0.5 overflow-hidden\">\n {voice.levels.map((level, index) => <span key={index} data-level={level} className=\"w-1 shrink-0 bg-muted-foreground\" style={{ height: `${Math.max(5, Math.sqrt(Math.min(1, level)) * 100)}%` }} />)}\n </div>}\n </div>\n {voice.pause && voice.resume && <Button variant=\"ghost\" size=\"icon\" data-testid=\"voice-pause\" aria-label={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} title={voice.paused ? labels.voiceResumeRecording : labels.voicePauseRecording} onClick={voice.paused ? voice.resume : voice.pause} className=\"min-h-11 min-w-11 shrink-0\">{voice.paused ? <PlayIcon aria-hidden=\"true\" /> : <PauseIcon aria-hidden=\"true\" />}</Button>}\n <Button variant=\"default\" size=\"icon\" data-testid={testId} data-state={voice.phase} aria-label={labels.send} title={labels.send} onClick={voice.finish} disabled={voice.phase === 'starting'} className=\"min-h-11 min-w-11 shrink-0\"><icons.send aria-hidden=\"true\" /></Button>\n </div>;\n}\n\nexport function VoiceMessageButton({ voice, disabled, primary = false, testId = 'voice-message-record' }: VoiceMessageButtonProps) {\n const { labels } = useChatText();\n if (voice.phase === 'starting' || voice.phase === 'recording') return <RecordingBar voice={voice} testId={testId} />;\n return <Button onClick={voice.start} variant={primary ? 'default' : 'ghost'} size=\"icon\" className=\"min-h-11 min-w-11 shrink-0\" data-testid={testId} data-state={voice.phase} disabled={disabled || !voice.canStart || voice.phase !== 'idle'} aria-label={labels.voiceRecord} title={labels.voiceRecord}><MicIcon aria-hidden=\"true\" /></Button>;\n}\n\nexport function PendingVoiceBubble({ pending }: PendingVoiceBubbleProps) {\n const { labels } = useChatText();\n if (!pending) return null;\n const failed = pending.phase === 'transcribe-failed' || pending.phase === 'upload-failed';\n const blocked = pending.phase === 'blocked' || pending.phase === 'unconfirmed';\n return <Message align=\"end\" data-testid=\"voice-pending\" data-voice-id={pending.id} data-state={pending.phase}><MessageContent><Bubble variant=\"muted\" align=\"end\" className={failed || blocked ? 'w-full max-w-full' : undefined}><BubbleContent>\n <div role={failed ? 'alert' : 'status'} className=\"flex items-center gap-2\">\n {!failed && !blocked && <Spinner aria-hidden=\"true\" />}\n {Number.isFinite(pending.seconds) && pending.seconds > 0 && <span data-testid=\"voice-pending-duration\">{voiceClock(pending.seconds)}</span>}\n <span>{pending.phase === 'upload-failed' ? labels.uploadFailedShort : pending.phase === 'transcribe-failed' ? labels.voiceTranscribeFailed : pending.phase === 'blocked' ? labels.voiceBlocked : pending.phase === 'unconfirmed' ? labels.voiceUnconfirmed : labels.voicePreparing}</span>\n </div>\n {(failed || blocked) && <div className=\"mt-2 grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2\">\n {failed && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-retry\" onClick={pending.retry}>{labels.retry}</Button>}\n {pending.audioOnly && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"outline\" data-testid=\"voice-audio-only\" onClick={pending.audioOnly}>{labels.voiceAudioOnly}</Button>}\n {pending.phase !== 'unconfirmed' && <Button size=\"sm\" className=\"h-auto min-h-11 min-w-0 whitespace-normal\" variant=\"ghost\" data-testid=\"voice-discard\" onClick={pending.discard}>{labels.voiceDiscard}</Button>}\n </div>}\n </BubbleContent></Bubble></MessageContent></Message>;\n}\n\n/** Tap-to-record voice messages for the chat: `voiceMessage={{ ...config, ...voiceMessagePlugin }}`. */\nexport const voiceMessagePlugin = { useRecorder: useVoiceMessage, Button: VoiceMessageButton, Bubble: PendingVoiceBubble } as const;\n",
34
31
  "type": "registry:component",
35
32
  "target": "components/pi-chat-voice/voice-message.tsx"
36
33
  }