diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c25235c65c..42e79cfbd2a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,6 +1,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" +import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, For } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" @@ -25,6 +25,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" @@ -56,6 +57,7 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useSpeechRecognition } from "@/hooks/use-speech-recognition" interface PromptInputProps { class?: string @@ -283,6 +285,84 @@ export const PromptInput: Component = (props) => { applyingHistory: false, }) + // Speech recognition state + const browserLangs = createMemo(() => { + const langs = Array.from(navigator.languages ?? [navigator.language]).filter(Boolean) + if (!langs.some((l) => l.startsWith("en"))) langs.push("en-US") + return langs + }) + + const [speechLang, setSpeechLang] = persisted( + Persist.global("speech-lang"), + createStore({ lang: navigator.language || "en-US" }), + ) + + const showLangSelector = createMemo(() => true) + + const getSpeechLang = () => speechLang.lang || navigator.language || "en-US" + + let finalTranscript = "" + let speechInsertCursor = 0 + let speechBaseParts: ContentPart[] = [] + + const speech = useSpeechRecognition({ + lang: getSpeechLang(), + onResult: (text, isFinal) => { + if (!text.trim()) return + + if (isFinal) { + finalTranscript += (finalTranscript ? " " : "") + text.trim() + } + + const spoken = isFinal ? finalTranscript : finalTranscript + (finalTranscript ? " " : "") + text.trim() + const before = speechBaseParts + const pos = speechInsertCursor + + const textBefore = before + .filter((p): p is ContentPart & { type: "text" } => p.type === "text") + .map((p) => p.content) + .join("") + .slice(0, pos) + const textAfter = before + .filter((p): p is ContentPart & { type: "text" } => p.type === "text") + .map((p) => p.content) + .join("") + .slice(pos) + const nonText = before.filter((p) => p.type !== "text") + + const merged = textBefore + spoken + textAfter + const newPart: ContentPart = { type: "text", content: merged, start: 0, end: merged.length } + const newParts: ContentPart[] = [...nonText, newPart] + const newCursor = pos + spoken.length + + prompt.set(newParts, newCursor) + + requestAnimationFrame(() => { + editorRef?.focus() + setCursorPosition(editorRef, newCursor) + queueScroll() + }) + }, + onError: (error) => { + console.error("Speech recognition error:", error) + }, + }) + + const toggleRecording = () => { + if (store.mode !== "normal") return + if (speech.state() !== "recording") { + finalTranscript = "" + speechInsertCursor = getCursorPosition(editorRef) ?? promptLength(prompt.current()) + speechBaseParts = [...prompt.current()] + } + speech.toggle() + } + + const stopRecording = () => { + speech.abort() // Stop and ignore any pending results + finalTranscript = "" // Clear accumulated transcript + } + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, @@ -1094,7 +1174,10 @@ export const PromptInput: Component = (props) => { shouldQueue: props.shouldQueue, onQueue: props.onQueue, onAbort: props.onAbort, - onSubmit: props.onSubmit, + onSubmit: () => { + stopRecording() + props.onSubmit?.() + }, }) const handleKeyDown = (event: KeyboardEvent) => { @@ -1303,11 +1386,12 @@ export const PromptInput: Component = (props) => { if (!(target instanceof HTMLElement)) return if ( target.closest( - '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"], [data-action="prompt-mic"]', ) ) { return } + stopRecording() editorRef?.focus() }} > @@ -1378,7 +1462,73 @@ export const PromptInput: Component = (props) => { }} /> -
+
+ +
+ + + + + + + + {speechLang.lang} + + + + setSpeechLang("lang", v as string)} + > + + {(lang) => ( + + {lang} + + + + + )} + + + + + + +
+
+ + + +
+ +
+
+
+ void + onError?: (error: string) => void + onStart?: () => void + onEnd?: () => void +} + +export function useSpeechRecognition(options: UseSpeechRecognitionOptions = {}) { + const [state, setState] = createSignal("idle") + const [transcript, setTranscript] = createSignal("") + const [isSupported, setIsSupported] = createSignal(false) + const [error, setError] = createSignal(null) + + let recognition: SpeechRecognition | null = null + let isAborted = false + + createEffect(() => { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition + + if (!SpeechRecognition) { + setIsSupported(false) + return + } + + setIsSupported(true) + + recognition = new SpeechRecognition() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = options.lang || navigator.language || "en-US" + + recognition.onstart = () => { + isAborted = false + setState("recording") + setError(null) + options.onStart?.() + } + + recognition.onresult = (event: SpeechRecognitionEvent) => { + if (isAborted) return + + let finalTranscript = "" + let interimTranscript = "" + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + if (result.isFinal) { + finalTranscript += result[0].transcript + } else { + interimTranscript += result[0].transcript + } + } + + const fullTranscript = finalTranscript || interimTranscript + setTranscript(fullTranscript) + options.onResult?.(fullTranscript, !!finalTranscript) + } + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + if (isAborted) return + + const errorMsg = + event.error === "not-allowed" ? "Microphone access denied" : `Speech recognition error: ${event.error}` + setError(errorMsg) + setState("error") + options.onError?.(errorMsg) + } + + recognition.onend = () => { + if (state() === "recording") { + setState("idle") + } + options.onEnd?.() + } + + onCleanup(() => { + if (recognition) { + recognition.stop() + recognition = null + } + }) + }) + + const start = () => { + if (!recognition || state() === "recording") return + + try { + isAborted = false + recognition.start() + } catch (err) { + setError("Failed to start recording") + setState("error") + } + } + + const stop = () => { + if (!recognition || state() !== "recording") return + + try { + recognition.stop() + setState("idle") + } catch (err) { + // Ignore errors when stopping + } + } + + const abort = () => { + isAborted = true + stop() + setTranscript("") + setError(null) + } + + const toggle = () => { + if (state() === "recording") { + stop() + } else { + start() + } + } + + const reset = () => { + setTranscript("") + setError(null) + setState("idle") + } + + return { + state, + transcript, + isSupported, + error, + start, + stop, + abort, + toggle, + reset, + } +} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c8f58c796e6..045b8feee18 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -258,6 +258,9 @@ export const dict = { "prompt.attachment.remove": "إزالة المرفق", "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", + "prompt.action.startVoice": "بدء الإدخال الصوتي", + "prompt.action.stopVoice": "إيقاف التسجيل", + "prompt.action.voiceNotSupported": "التعرف على الكلام غير مدعوم", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 3112e91bbea..6892118eb7b 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -258,6 +258,9 @@ export const dict = { "prompt.attachment.remove": "Remover anexo", "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", + "prompt.action.startVoice": "Iniciar entrada de voz", + "prompt.action.stopVoice": "Parar gravação", + "prompt.action.voiceNotSupported": "Reconhecimento de voz não suportado", "prompt.toast.pasteUnsupported.title": "Anexo não suportado", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index f2dbd8493c6..7a301183b04 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -278,6 +278,9 @@ export const dict = { "prompt.attachment.remove": "Ukloni prilog", "prompt.action.send": "Pošalji", "prompt.action.stop": "Zaustavi", + "prompt.action.startVoice": "Pokreni glasovni unos", + "prompt.action.stopVoice": "Zaustavi snimanje", + "prompt.action.voiceNotSupported": "Prepoznavanje govora nije podržano", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index e90e1071ad5..5abcb3f00bb 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -276,6 +276,9 @@ export const dict = { "prompt.attachment.remove": "Fjern vedhæftning", "prompt.action.send": "Send", "prompt.action.stop": "Stop", + "prompt.action.startVoice": "Start stemmeinput", + "prompt.action.stopVoice": "Stop optagelse", + "prompt.action.voiceNotSupported": "Talegenkendelse understøttes ikke", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 69658b29e9a..4bc5d424cbb 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -263,6 +263,9 @@ export const dict = { "prompt.attachment.remove": "Anhang entfernen", "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", + "prompt.action.startVoice": "Spracheingabe starten", + "prompt.action.stopVoice": "Aufnahme beenden", + "prompt.action.voiceNotSupported": "Spracherkennung wird nicht unterstützt", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad9..9cf732fae82 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -278,6 +278,9 @@ export const dict = { "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", + "prompt.action.startVoice": "Start voice input", + "prompt.action.stopVoice": "Stop recording", + "prompt.action.voiceNotSupported": "Speech recognition not supported", "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9e36e4de6db..c0bce3a5e5f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -277,6 +277,9 @@ export const dict = { "prompt.attachment.remove": "Eliminar adjunto", "prompt.action.send": "Enviar", "prompt.action.stop": "Detener", + "prompt.action.startVoice": "Iniciar entrada de voz", + "prompt.action.stopVoice": "Detener grabación", + "prompt.action.voiceNotSupported": "Reconocimiento de voz no compatible", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f53b3882c6d..de11d6df36a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -258,6 +258,9 @@ export const dict = { "prompt.attachment.remove": "Supprimer la pièce jointe", "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", + "prompt.action.startVoice": "Démarrer la saisie vocale", + "prompt.action.stopVoice": "Arrêter l'enregistrement", + "prompt.action.voiceNotSupported": "Reconnaissance vocale non prise en charge", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge", "prompt.toast.pasteUnsupported.description": "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index d66a7341d5a..f8370039c62 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -257,6 +257,9 @@ export const dict = { "prompt.attachment.remove": "添付ファイルを削除", "prompt.action.send": "送信", "prompt.action.stop": "停止", + "prompt.action.startVoice": "音声入力を開始", + "prompt.action.stopVoice": "録音を停止", + "prompt.action.voiceNotSupported": "音声認識に対応していません", "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d534c27e8fb..a0628aad429 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -261,6 +261,9 @@ export const dict = { "prompt.attachment.remove": "첨부 파일 제거", "prompt.action.send": "전송", "prompt.action.stop": "중지", + "prompt.action.startVoice": "음성 입력 시작", + "prompt.action.stopVoice": "녹음 중지", + "prompt.action.voiceNotSupported": "음성 인식이 지원되지 않습니다", "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c23d0a27927..7ae434b0c7a 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -280,6 +280,9 @@ export const dict = { "prompt.attachment.remove": "Fjern vedlegg", "prompt.action.send": "Send", "prompt.action.stop": "Stopp", + "prompt.action.startVoice": "Start taleinndata", + "prompt.action.stopVoice": "Stopp opptak", + "prompt.action.voiceNotSupported": "Talegjenkjenning støttes ikke", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index dac847b217f..06b5f625e8d 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -259,6 +259,9 @@ export const dict = { "prompt.attachment.remove": "Usuń załącznik", "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", + "prompt.action.startVoice": "Rozpocznij wprowadzanie głosowe", + "prompt.action.stopVoice": "Zatrzymaj nagrywanie", + "prompt.action.voiceNotSupported": "Rozpoznawanie mowy nie jest obsługiwane", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 684d5deecd0..dfbfe52da93 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -277,6 +277,9 @@ export const dict = { "prompt.attachment.remove": "Удалить вложение", "prompt.action.send": "Отправить", "prompt.action.stop": "Остановить", + "prompt.action.startVoice": "Начать голосовой ввод", + "prompt.action.stopVoice": "Остановить запись", + "prompt.action.voiceNotSupported": "Распознавание речи не поддерживается", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 80f0da94ec6..e3d43c44225 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -277,6 +277,9 @@ export const dict = { "prompt.attachment.remove": "เอาไฟล์แนบออก", "prompt.action.send": "ส่ง", "prompt.action.stop": "หยุด", + "prompt.action.startVoice": "เริ่มป้อนข้อมูลด้วยเสียง", + "prompt.action.stopVoice": "หยุดบันทึกเสียง", + "prompt.action.voiceNotSupported": "ไม่รองรับการรู้จำเสียง", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 9041e0dd07f..3b02229e68f 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -282,6 +282,9 @@ export const dict = { "prompt.attachment.remove": "Eki kaldır", "prompt.action.send": "Gönder", "prompt.action.stop": "Durdur", + "prompt.action.startVoice": "Sesli girişi başlat", + "prompt.action.stopVoice": "Kaydı durdur", + "prompt.action.voiceNotSupported": "Konuşma tanıma desteklenmiyor", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index cf64ca9b2c5..250644b93a8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -297,6 +297,9 @@ export const dict = { "prompt.attachment.remove": "移除附件", "prompt.action.send": "发送", "prompt.action.stop": "停止", + "prompt.action.startVoice": "开始语音输入", + "prompt.action.stopVoice": "停止录音", + "prompt.action.voiceNotSupported": "不支持语音识别", "prompt.toast.pasteUnsupported.title": "不支持的附件", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 02c00d17a22..16cf83a9931 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -277,6 +277,9 @@ export const dict = { "prompt.attachment.remove": "移除附件", "prompt.action.send": "傳送", "prompt.action.stop": "停止", + "prompt.action.startVoice": "開始語音輸入", + "prompt.action.stopVoice": "停止錄音", + "prompt.action.voiceNotSupported": "不支援語音辨識", "prompt.toast.pasteUnsupported.title": "不支援的附件", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", diff --git a/packages/app/src/types/speech-recognition.d.ts b/packages/app/src/types/speech-recognition.d.ts new file mode 100644 index 00000000000..8c816711075 --- /dev/null +++ b/packages/app/src/types/speech-recognition.d.ts @@ -0,0 +1,46 @@ +// Type declarations for Web Speech API +interface SpeechRecognition extends EventTarget { + continuous: boolean + interimResults: boolean + lang: string + onstart: ((this: SpeechRecognition, ev: Event) => any) | null + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null + onend: ((this: SpeechRecognition, ev: Event) => any) | null + start(): void + stop(): void + abort(): void +} + +interface SpeechRecognitionEvent extends Event { + readonly resultIndex: number + readonly results: SpeechRecognitionResultList +} + +interface SpeechRecognitionResultList { + readonly length: number + item(index: number): SpeechRecognitionResult + [index: number]: SpeechRecognitionResult +} + +interface SpeechRecognitionResult { + readonly isFinal: boolean + readonly length: number + item(index: number): SpeechRecognitionAlternative + [index: number]: SpeechRecognitionAlternative +} + +interface SpeechRecognitionAlternative { + readonly transcript: string + readonly confidence: number +} + +interface SpeechRecognitionErrorEvent extends Event { + readonly error: string + readonly message: string +} + +interface Window { + SpeechRecognition: new () => SpeechRecognition + webkitSpeechRecognition: new () => SpeechRecognition +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index e2eaf107a67..370f237e8e1 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -102,6 +102,8 @@ const icons = { link: ``, providers: ``, models: ``, + mic: ``, + "mic-off": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index f9a09df379e..ef7b2395c9d 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -33,6 +33,23 @@ } } +@keyframes mic-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.15); + } +} + +[data-recording="true"] { + animation: mic-pulse 1s ease-in-out infinite; + color: rgb(239 68 68); +} + @keyframes fadeUp { from { opacity: 0;