@hachej/boring-ui-kit 0.4.13 → 0.4.15

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 (57) hide show
  1. package/README.md +12 -2
  2. package/bin/boring-ui-blocks.mjs +8 -1
  3. package/dist/file-tree-view.d.ts +48 -3
  4. package/dist/file-tree-view.js +202 -12
  5. package/dist/file-tree.d.ts +31 -0
  6. package/dist/file-tree.js +122 -1
  7. package/dist/voice-capture.d.ts +10 -0
  8. package/dist/voice-capture.js +235 -39
  9. package/package.json +2 -2
  10. package/r/agent-target.json +4 -4
  11. package/r/app-shell.json +15 -15
  12. package/r/board-viewer.json +5 -5
  13. package/r/button.json +1 -1
  14. package/r/diagram-viewer.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +13 -13
  17. package/r/feedback-preview.json +1 -1
  18. package/r/feedback.json +6 -6
  19. package/r/file-tree.json +8 -7
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +1 -1
  23. package/r/maquette-viewer.json +2 -2
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +1 -1
  26. package/r/pi-ambient.json +9 -9
  27. package/r/pi-app.json +21 -21
  28. package/r/pi-artifacts.json +1 -1
  29. package/r/pi-chat-approval.json +1 -1
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +1 -1
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +1 -1
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +3 -3
  39. package/r/pi-chat.json +5 -5
  40. package/r/pi-workspace.json +4 -4
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +2 -2
  43. package/r/record-viewer.json +1 -1
  44. package/r/registry.json +169 -167
  45. package/r/requests-board.json +1 -1
  46. package/r/shared-chat.json +12 -11
  47. package/r/task-list-viewer.json +2 -2
  48. package/r/version-history.json +3 -3
  49. package/r/viewer-code.json +2 -2
  50. package/r/viewer-frame.json +5 -5
  51. package/r/viewer-html.json +4 -4
  52. package/r/viewer-image.json +2 -2
  53. package/r/viewer-markdown.json +5 -5
  54. package/r/viewer-pdf.json +3 -3
  55. package/r/viewers.json +6 -6
  56. package/r/windows.json +2 -2
  57. package/styles/file-tree.css +13 -0
@@ -4,12 +4,12 @@
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.13",
7
+ "@hachej/boring-ui-kit@^0.4.15",
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.13/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
13
13
  "bubble",
14
14
  "button",
15
15
  "message",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  {
26
26
  "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/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",
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",
28
28
  "type": "registry:hook",
29
29
  "target": "components/pi-chat-voice/use-voice-message.ts"
30
30
  },
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.13",
8
- "@hachej/boring-files@^0.4.13",
9
- "@hachej/boring-feedback@^0.4.13",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
9
+ "@hachej/boring-feedback@^0.4.15",
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.13/r/utils.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/button.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
23
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
24
24
  "dialog",
25
25
  "tooltip",
26
26
  "trace",
@@ -8,10 +8,10 @@
8
8
  "react-dom@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/dock.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/dock.json",
15
15
  "badge",
16
16
  "card"
17
17
  ],
@@ -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.13/r/pi-app.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-ambient.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-app.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-ambient.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
16
16
  "button"
17
17
  ],
18
18
  "files": [
@@ -9,8 +9,8 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
14
14
  "button",
15
15
  "input",
16
16
  "label",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
12
12
  "table",
13
13
  "badge",
14
14
  "item",