@hachej/boring-ui-kit 0.4.10 → 0.4.12
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.
- package/dist/shared-chat/socket.js +3 -1
- package/dist/shared-chat/source.d.ts +7 -1
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +10 -8
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +2 -2
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +7 -6
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +32 -0
- package/r/maquette-viewer.json +3 -3
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +3 -3
- package/r/pi-ambient.json +11 -8
- package/r/pi-app.json +18 -16
- package/r/pi-artifacts.json +32 -0
- package/r/pi-chat-approval.json +26 -0
- package/r/pi-chat-attachments.json +25 -0
- package/r/pi-chat-markdown.json +26 -0
- package/r/pi-chat-mentions.json +23 -0
- package/r/pi-chat-pickers.json +24 -0
- package/r/pi-chat-plugins.json +31 -0
- package/r/pi-chat-question.json +29 -0
- package/r/pi-chat-slash.json +26 -0
- package/r/pi-chat-subagent.json +34 -0
- package/r/pi-chat-voice.json +39 -0
- package/r/pi-chat.json +15 -97
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +438 -177
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +13 -9
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +7 -7
- package/r/windows.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "pi-chat-voice",
|
|
4
|
+
"title": "Pi chat voice messages",
|
|
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
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
|
+
"react@^19.0.0",
|
|
9
|
+
"lucide-react@^1.51.0"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
|
|
13
|
+
"bubble",
|
|
14
|
+
"button",
|
|
15
|
+
"message",
|
|
16
|
+
"spinner"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"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",
|
|
22
|
+
"type": "registry:hook",
|
|
23
|
+
"target": "components/pi-chat-voice/use-hold-to-record.ts"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
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",
|
|
28
|
+
"type": "registry:hook",
|
|
29
|
+
"target": "components/pi-chat-voice/use-voice-message.ts"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"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",
|
|
34
|
+
"type": "registry:component",
|
|
35
|
+
"target": "components/pi-chat-voice/voice-message.tsx"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"type": "registry:block"
|
|
39
|
+
}
|