From 8459d7aec441201e39752fccfc885b2bf446eade Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 8 Nov 2026 23:42:46 +0000 Subject: [PATCH 1/9] fix(web): voice settings read like the other settings pages The microphone test gets its own row with an inline record, level, and playback strip, so it no longer crushes the microphone description. Selects show short values, descriptions are one sentence, dictionary counts are gone, the destination is a normal row, and the model browser uses a search plus language and sort menus instead of a cramped two-pane list. Custom cleanup instructions start from the built-in prompt and the editor only shows for Custom. --- .../components/settings/MicrophoneTest.tsx | 200 ++-- .../settings/SpeechModelComparison.tsx | 27 - .../VoiceSettingsPanel.browser.test.tsx | 10 +- .../settings/VoiceSettingsPanel.tsx | 894 +++++++++--------- 4 files changed, 561 insertions(+), 570 deletions(-) diff --git a/apps/web/src/components/settings/MicrophoneTest.tsx b/apps/web/src/components/settings/MicrophoneTest.tsx index d3597e2dea67..14482bfbed91 100644 --- a/apps/web/src/components/settings/MicrophoneTest.tsx +++ b/apps/web/src/components/settings/MicrophoneTest.tsx @@ -1,12 +1,14 @@ import { MICROPHONE_PERMISSION_ERROR } from "@t3tools/client-runtime/voice-input"; -import { CheckIcon, MicIcon, PauseIcon, PlayIcon, RotateCcwIcon, XIcon } from "lucide-react"; +import { MicIcon, PauseIcon, PlayIcon, RotateCcwIcon, SquareIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import workletUrl from "../../speech/pcmWorklet.ts?worker&url"; -import { VoiceInputErrorPill } from "../chat/VoiceInputErrorPill"; -import { VoiceInputPill, VoiceWaveform } from "../chat/VoiceInputPill"; +import { VoiceWaveform } from "../chat/VoiceInputPill"; +import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; const LOOPBACK_SECONDS = 10; const formatTime = (seconds: number) => @@ -181,70 +183,11 @@ export function MicrophoneTest({ microphoneId }: { microphoneId: string }) { setError(null); }; - if (error) { - return ( - void start()} - onDismiss={cancel} - /> - ); - } - if (phase === "idle") { - return ( - - ); - } - if (phase === "ready" && recording) { - return ( - void start()} - onDone={cancel} - /> - ); - } - return ( - - - {phase === "recording" ? ( - <> - - - - {formatTime(elapsed)} / {formatTime(LOOPBACK_SECONDS)} - - - - ) : ( - - Opening microphone… - - )} + const control = + phase === "recording" || phase === "starting" ? ( + ) : ( + - + ); + const panel = error ? ( +

+ {error} +

+ ) : phase === "starting" ? ( + + Opening microphone… + + ) : phase === "recording" ? ( + <> + +
+ +
+ + {formatTime(elapsed)} / {formatTime(LOOPBACK_SECONDS)} + + + + ) : phase === "ready" && recording ? ( + + ) : null; + + return ( + + {panel ? ( +
+ {panel} + +
+ ) : null} +
); } -function MicrophonePlayback({ - src, - duration, - onRetry, - onDone, -}: { - src: string; - duration: number; - onRetry(): void; - onDone(): void; -}) { +function MicrophonePlayback({ src, duration }: { src: string; duration: number }) { const audio = useRef(null); const [playing, setPlaying] = useState(false); const [position, setPosition] = useState(0); @@ -292,7 +289,7 @@ function MicrophonePlayback({ }; return ( -
+ <>
+ )} + + {formatTime(position)} / {formatTime(duration)} + + ); } diff --git a/apps/web/src/components/settings/SpeechModelComparison.tsx b/apps/web/src/components/settings/SpeechModelComparison.tsx index 47f8ca57e998..4842c688b36d 100644 --- a/apps/web/src/components/settings/SpeechModelComparison.tsx +++ b/apps/web/src/components/settings/SpeechModelComparison.tsx @@ -1,7 +1,5 @@ import type { EnvironmentSpeechModel, SpeechBenchmarkTier } from "@t3tools/contracts"; -import { CheckIcon } from "lucide-react"; -import { cn } from "../../lib/utils"; import { languageLabel } from "./SpeechLanguageSelect"; const commonLanguages = [ @@ -45,31 +43,6 @@ export function orderSpeechLanguages(languages: readonly string[], preferred: re ); } -export function LanguageRow({ - label, - selected, - onClick, -}: { - label: string; - selected: boolean; - onClick: () => void; -}) { - return ( - - ); -} - export function ModelBenchmarks({ model, languages, diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx index 06f4496f3378..888da736f89a 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx @@ -331,17 +331,23 @@ it("tests the selected model after download and preserves its transcript across const result = screen.getByRole("status", { name: "Test transcript" }); await expect.element(result).toHaveTextContent(transcript); - const search = screen.getByRole("textbox", { name: "Search transcription models" }); + const search = screen.getByRole("searchbox", { name: "Search transcription models" }); await userEvent.fill(search, "no matching model"); await expect.element(screen.getByText("No models match.")).toBeVisible(); await userEvent.clear(search); + const languages = screen.getByRole("button", { name: /^Filter by language/ }); + await userEvent.click(languages); await userEvent.fill(screen.getByRole("textbox", { name: "Search languages" }), "fr"); - const french = screen.getByRole("button", { name: "French", exact: true }); + const french = screen.getByRole("menuitemcheckbox", { name: "French", exact: true }); await userEvent.click(french); + await userEvent.keyboard("{Escape}"); await expect.element(screen.getByText("Bilingual", { exact: true })).toBeVisible(); await expect.element(screen.getByText("B+", { exact: true })).toBeVisible(); expect(screen.container.querySelector("#transcription-test")).toHaveAttribute("hidden"); + await userEvent.click(languages); + await userEvent.fill(screen.getByRole("textbox", { name: "Search languages" }), "fr"); await userEvent.click(french); + await userEvent.keyboard("{Escape}"); await expect.element(result).toBeVisible(); await vi.waitFor(() => expect( diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index 495abdefb6b6..dd65e553d50e 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -30,6 +30,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import { resolveSpeechPostProcessingModelSelection } from "@t3tools/shared/serverSettings"; import { cloudSpeechLanguages } from "@t3tools/shared/speech"; import { + ArrowDownUpIcon, AudioLinesIcon, CheckIcon, DownloadIcon, @@ -38,10 +39,12 @@ import { MonitorIcon, MicOffIcon, MicIcon, + PlusIcon, RefreshCwIcon, + SearchIcon, StarIcon, - SlidersHorizontalIcon, Trash2Icon, + TriangleAlertIcon, XIcon, } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; @@ -88,6 +91,7 @@ import { MenuRadioGroup, MenuRadioItem, MenuRadioItemIndicator, + MenuSearchInput, MenuSeparator, MenuTrigger, } from "../ui/menu"; @@ -105,7 +109,7 @@ import { Textarea } from "../ui/textarea"; import { toastManager } from "../ui/toast"; import { MicrophoneTest } from "./MicrophoneTest"; import { CloudVoiceConfiguration } from "./CloudVoiceConfiguration"; -import { LanguageRow, ModelBenchmarks, orderSpeechLanguages } from "./SpeechModelComparison"; +import { ModelBenchmarks, orderSpeechLanguages } from "./SpeechModelComparison"; import { languageLabel, SpeechLanguageSelect } from "./SpeechLanguageSelect"; import { filterSpeechModels, type SpeechModelSort } from "./speechModelBrowser"; import { AdvancedSettings } from "./AdvancedSettings"; @@ -135,10 +139,10 @@ function CloudSpeechLogo({ provider }: { provider: CloudSpeechProvider }) { } const SYSTEM_DEFAULT = "system-default"; const formatSize = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`; -const SHORTCUT_MODE_LABELS: Record = { - auto: "Auto: press to toggle, hold to record", - hold: "Hold: record while held", - toggle: "Toggle: press to start, press again to stop", +const SHORTCUT_MODES: Record = { + auto: { label: "Auto", hint: "Tap to toggle, hold to talk" }, + hold: { label: "Hold", hint: "Record while held" }, + toggle: { label: "Toggle", hint: "Tap to start, tap to stop" }, }; const UNLOAD_LABELS: Record = { never: "Never", @@ -188,107 +192,113 @@ function ModelCard(props: ModelCardProps) { const downloading = model.state === "downloading" || model.state === "verifying"; const progress = model.downloaded === undefined ? 0 : (model.downloaded / model.size) * 100; return ( -
-
-
- {model.name} - {model.recommended ? ( - } - label="Recommended" - tooltip="A good default pick for the languages it covers." - /> - ) : null} - {model.supportsStreaming ? ( - } - label="Live preview" - tooltip="Shows words in your draft as you speak, before the recording finishes." - /> +
+
+
+
+ {model.name} + {model.recommended ? ( + } + label="Recommended" + tooltip="A good default pick for the languages it covers." + /> + ) : null} + {model.supportsStreaming ? ( + } + label="Live preview" + tooltip="Shows words in your draft as you speak, before the recording finishes." + /> + ) : null} + {model.supportsTranslation ? ( + } + label="Translate" + tooltip="Can write English text from speech in another language. Turn on Translate to English to use it." + /> + ) : null} +
+ {model.description ? ( +

+ {model.description} +

) : null} - {model.supportsTranslation ? ( - } - label="Translate" - tooltip="Can write English text from speech in another language. Turn on Translate to English to use it." - /> + 1 + ? [`${model.languages.length} languages`] + : model.benchmark + ? [] + : [languageLabel(model.languages[0] ?? "")]), + formatSize(model.size), + ]} + /> + {downloading ? ( +
+
+
+
+

+ {model.state === "verifying" + ? "Verifying download…" + : `${Math.round(progress)}% downloaded`} +

+
) : null}
-

{model.description}

- 1 - ? [`${model.languages.length} languages`] - : model.benchmark - ? [] - : [languageLabel(model.languages[0] ?? "")]), - formatSize(model.size), - ]} - /> - {downloading ? ( -
-
+ {props.testControls} + {downloading ? ( +
- ) : null} -
-
- {props.testControls} - {downloading ? ( - - ) : model.state === "downloadable" ? ( - - ) : model.active ? ( - - ) : ( - - )} - {model.state === "installed" ? ( - - ) : null} + + + ) : model.state === "downloadable" ? ( + + ) : model.active ? ( + + ) : ( + + )} + {model.state === "installed" ? ( + + ) : null} +
{props.children}
@@ -340,13 +350,13 @@ function DictionaryEditor(props: { }; return ( <> -
+