From bf07904789cb4f8150479d54d7ec089c0969c987 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 24 Mar 2026 22:42:27 +0000 Subject: [PATCH] feat(speech): make prompt input push to talk --- packages/ui/src/components/prompt-input.tsx | 62 ++++++++++++++++--- .../prompt-input/usePromptVoiceInput.ts | 60 +++++++++--------- .../ui/src/styles/messaging/prompt-input.css | 8 ++- 3 files changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 1ae41690..30a7c38f 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" -import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Square } from "lucide-solid" +import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid" import UnifiedPicker from "./unified-picker" import ExpandButton from "./expand-button" import { clearAttachments, removeAttachment } from "../stores/attachments" @@ -425,6 +425,32 @@ export default function PromptInput(props: PromptInputProps) { const instance = () => getActiveInstance() + let voiceButtonPressed = false + + const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => { + if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return + voiceButtonPressed = true + + if (event instanceof PointerEvent) { + const target = event.currentTarget + if (target instanceof HTMLElement) { + try { + target.setPointerCapture(event.pointerId) + } catch { + // no-op + } + } + } + + void voiceInput.startRecording() + } + + const endVoicePress = () => { + if (!voiceButtonPressed) return + voiceButtonPressed = false + voiceInput.stopRecording() + } + return (
void voiceInput.toggleRecording()} + onPointerDown={(event) => { + event.preventDefault() + beginVoicePress(event) + }} + onPointerUp={(event) => { + event.preventDefault() + endVoicePress() + }} + onPointerCancel={() => endVoicePress()} + onLostPointerCapture={() => endVoicePress()} + onKeyDown={(event) => { + if (event.repeat) return + if (event.key !== " " && event.key !== "Enter") return + event.preventDefault() + beginVoicePress(event) + }} + onKeyUp={(event) => { + if (event.key !== " " && event.key !== "Enter") return + event.preventDefault() + endVoicePress() + }} + onBlur={() => endVoicePress()} disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())} aria-label={voiceInput.buttonTitle()} title={voiceInput.buttonTitle()} > - - {formatVoiceTimer(voiceInput.elapsedMs())} -