From bcdb42750a6e15435106a71998202a42e9fde7c3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 21 Aug 2026 19:38:00 -0400 Subject: [PATCH 1/9] fix(voice): open full settings for setup --- src/app/AppShell.tsx | 54 +------ src/features/chat/ui/ChatView.tsx | 31 +--- .../lib/pendingVoiceStart.test.ts | 46 ------ .../lib/pendingVoiceStart.ts | 49 ------ ...t.tsx => PocketVoiceSetupContent.test.tsx} | 149 ++---------------- ...Dialog.tsx => PocketVoiceSetupContent.tsx} | 126 +-------------- .../ui/SiriVoiceSettings.test.tsx | 32 +++- .../ui/SiriVoiceSettings.tsx | 44 +++--- .../ui/VoiceSettings.test.tsx | 40 +++++ .../voice-conversation/ui/VoiceSettings.tsx | 33 ++-- src/shared/i18n/locales/en/settings.json | 5 - src/shared/i18n/locales/es/settings.json | 5 - 12 files changed, 132 insertions(+), 482 deletions(-) delete mode 100644 src/features/voice-conversation/lib/pendingVoiceStart.test.ts delete mode 100644 src/features/voice-conversation/lib/pendingVoiceStart.ts rename src/features/voice-conversation/ui/{PocketVoiceSetupDialog.test.tsx => PocketVoiceSetupContent.test.tsx} (65%) rename src/features/voice-conversation/ui/{PocketVoiceSetupDialog.tsx => PocketVoiceSetupContent.tsx} (72%) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index c08504812..0975f4744 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -31,6 +31,7 @@ import { } from "@/features/settings/ui/settingsSections"; import { OPEN_SETTINGS_EVENT, + requestOpenSettings, type AgentBuilderProviderSetupReturnTarget, type OpenSettingsEventDetail, } from "@/features/settings/lib/settingsEvents"; @@ -226,15 +227,8 @@ import { useOnboardingState } from "@/features/onboarding/model"; import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; import { useSiriVoiceSetup } from "@/features/voice-conversation/hooks/useSiriVoiceSetup"; -import { PocketVoiceSetupDialog } from "@/features/voice-conversation/ui/PocketVoiceSetupDialog"; import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voiceOutputPreference"; import { isVoiceSetupReady } from "@/features/voice-conversation/lib/voiceSetupReadiness"; -import { - cancelPendingVoiceStart, - continuePendingVoiceStart, - deferPendingVoiceStart, - type DeferredPendingVoiceStart, -} from "@/features/voice-conversation/lib/pendingVoiceStart"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; import { getOptimisticArtifactCwd } from "@/shared/artifacts/sessionArtifactLocation"; import { @@ -726,10 +720,6 @@ export function AppShell({ globalSiriVoiceSetup.status, globalVoiceOutput.backend, ); - const [globalPocketVoiceSetupOpen, setGlobalPocketVoiceSetupOpen] = - useState(false); - const pendingGlobalVoiceStartRef = - useRef | null>(null); const voiceConversationWasEnabledRef = useRef(capabilities.voiceConversation); useEffect(() => { const wasEnabled = voiceConversationWasEnabledRef.current; @@ -745,8 +735,6 @@ export function AppShell({ // The native process survives renderer reloads and may be owned by another // window, so an explicit on-to-off transition must clean up active use. // Mounting with the experiment already off performs no Voice native work. - cancelPendingVoiceStart(pendingGlobalVoiceStartRef); - setGlobalPocketVoiceSetupOpen(false); void stopVoiceConversation().catch(() => undefined); }, [capabilities.voiceConversation, stopVoiceConversation]); const sessions = useChatSessionStore(selectSessions); @@ -3213,18 +3201,11 @@ export function AppShell({ ); const handleGlobalVoiceConversationStart = useCallback( - ( - payload: GlobalComposerExpandPayload, - setupComplete = false, - ): Promise => { + (payload: GlobalComposerExpandPayload): Promise => { if (!capabilities.voiceConversation) return Promise.resolve(false); - if (!setupComplete && !globalVoiceReady) { - const pending = deferPendingVoiceStart( - pendingGlobalVoiceStartRef, - payload, - ); - setGlobalPocketVoiceSetupOpen(true); - return pending; + if (!globalVoiceReady) { + requestOpenSettings("voice"); + return Promise.resolve(false); } const options = payload.options; @@ -3319,22 +3300,6 @@ export function AppShell({ t, ], ); - const handleGlobalPocketVoiceSetupOpenChange = useCallback( - (open: boolean) => { - if (!open) { - cancelPendingVoiceStart(pendingGlobalVoiceStartRef); - } - setGlobalPocketVoiceSetupOpen(open); - }, - [], - ); - const handleGlobalPocketVoiceUseSelected = useCallback(() => { - setGlobalPocketVoiceSetupOpen(false); - void continuePendingVoiceStart(pendingGlobalVoiceStartRef, (payload) => - handleGlobalVoiceConversationStart(payload, true), - ); - }, [handleGlobalVoiceConversationStart]); - const handleStartConnectionSetupChat = useCallback( (request: SetupChatRequest) => { guardAppNavigation(() => { @@ -5325,15 +5290,6 @@ export function AppShell({ )} - state.requestStart, - ); - const [pocketVoiceSetupOpen, setPocketVoiceSetupOpen] = useState(false); - const pendingPocketVoiceStartRef = useRef(null); const voiceConversation = useVoiceConversationController({ sessionId, // Voice delivery only needs to wait for admission. Holding its per-session @@ -259,8 +252,7 @@ export function ChatView({ isGooseSession: controller.selectedProvider === "goose", pocketReady: voiceReady, onPocketSetupRequired: () => { - pendingPocketVoiceStartRef.current = sessionId; - setPocketVoiceSetupOpen(true); + requestOpenSettings("voice"); }, readOnly: Boolean(readOnlyStatus), disabled: @@ -270,16 +262,6 @@ export function ChatView({ !controller.workspaceContextReady || controller.queue.queuedMessage !== null, }); - const handlePocketVoiceSetupOpenChange = useCallback((open: boolean) => { - if (!open) pendingPocketVoiceStartRef.current = null; - setPocketVoiceSetupOpen(open); - }, []); - const handlePocketVoiceUseSelected = useCallback(() => { - const shouldStart = - consumePendingVoiceStart(pendingPocketVoiceStartRef) === sessionId; - setPocketVoiceSetupOpen(false); - if (shouldStart) requestVoiceConversationStart(sessionId); - }, [requestVoiceConversationStart, sessionId]); const isAgentBuilderOpen = agentBuilderOpenForLayout; const patchSession = useChatSessionStore((s) => s.patchSession); const agentBuilderContextState = effectiveSession?.agentBuilderContextState; @@ -999,15 +981,6 @@ export function ChatView({ sessionCwd={controller.sessionArtifactCwd} sessionId={sessionId} > - { - it("continues a deferred setup action exactly once", () => { - const pending = { current: { sessionId: "session-1" } }; - - expect(consumePendingVoiceStart(pending)).toEqual({ - sessionId: "session-1", - }); - expect(consumePendingVoiceStart(pending)).toBeNull(); - }); - - it("settles the originating action after setup succeeds", async () => { - const pending = { current: null }; - const result = deferPendingVoiceStart(pending, { - text: "keep this draft", - }); - - expect( - await continuePendingVoiceStart(pending, async (payload) => { - expect(payload).toEqual({ text: "keep this draft" }); - return true; - }), - ).toBe(true); - await expect(result).resolves.toBe(true); - expect(pending.current).toBeNull(); - }); - - it("rejects the originating action when setup is dismissed", async () => { - const pending = { current: null }; - const result = deferPendingVoiceStart(pending, { - text: "keep this draft", - }); - - cancelPendingVoiceStart(pending); - - await expect(result).resolves.toBe(false); - expect(pending.current).toBeNull(); - }); -}); diff --git a/src/features/voice-conversation/lib/pendingVoiceStart.ts b/src/features/voice-conversation/lib/pendingVoiceStart.ts deleted file mode 100644 index 0a4a31b7c..000000000 --- a/src/features/voice-conversation/lib/pendingVoiceStart.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface PendingVoiceStart { - current: T | null; -} - -export interface DeferredPendingVoiceStart { - payload: T; - resolve: (accepted: boolean) => void; -} - -export function consumePendingVoiceStart( - pending: PendingVoiceStart, -): T | null { - const value = pending.current; - pending.current = null; - return value; -} - -export function deferPendingVoiceStart( - pending: PendingVoiceStart>, - payload: T, -): Promise { - consumePendingVoiceStart(pending)?.resolve(false); - return new Promise((resolve) => { - pending.current = { payload, resolve }; - }); -} - -export function cancelPendingVoiceStart( - pending: PendingVoiceStart>, -): void { - consumePendingVoiceStart(pending)?.resolve(false); -} - -export async function continuePendingVoiceStart( - pending: PendingVoiceStart>, - start: (payload: T) => Promise, -): Promise { - const deferred = consumePendingVoiceStart(pending); - if (!deferred) return false; - - try { - const accepted = await start(deferred.payload); - deferred.resolve(accepted); - return accepted; - } catch { - deferred.resolve(false); - return false; - } -} diff --git a/src/features/voice-conversation/ui/PocketVoiceSetupDialog.test.tsx b/src/features/voice-conversation/ui/PocketVoiceSetupContent.test.tsx similarity index 65% rename from src/features/voice-conversation/ui/PocketVoiceSetupDialog.test.tsx rename to src/features/voice-conversation/ui/PocketVoiceSetupContent.test.tsx index cf857881a..4d92030b3 100644 --- a/src/features/voice-conversation/ui/PocketVoiceSetupDialog.test.tsx +++ b/src/features/voice-conversation/ui/PocketVoiceSetupContent.test.tsx @@ -3,14 +3,14 @@ import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/test/render"; -import { PocketVoiceSetupDialog } from "./PocketVoiceSetupDialog"; +import { PocketVoiceSetupContent } from "./PocketVoiceSetupContent"; import type { PocketVoiceStatus } from "../api/pocketVoice"; if (!HTMLElement.prototype.hasPointerCapture) { HTMLElement.prototype.hasPointerCapture = () => false; } -describe("PocketVoiceSetupDialog", () => { +describe("PocketVoiceSetupContent", () => { const baseStatus: PocketVoiceStatus = { statusRevision: 0, installed: false, @@ -41,9 +41,9 @@ describe("PocketVoiceSetupDialog", () => { const setup = ( status: PocketVoiceStatus, overrides: Partial< - ComponentProps["setup"] + ComponentProps["setup"] > = {}, - ): ComponentProps["setup"] => ({ + ): ComponentProps["setup"] => ({ status, loading: false, error: null, @@ -57,108 +57,10 @@ describe("PocketVoiceSetupDialog", () => { ...overrides, }); - it("puts speech input before speech output", () => { - renderWithProviders( - , - ); - - expect( - screen.getByRole("heading", { name: "Voice conversation" }), - ).toBeInTheDocument(); - expect( - screen.getByText( - "Install speech recognition and choose how Berd speaks during Voice Conversation.", - ), - ).toBeInTheDocument(); - const input = screen.getByRole("heading", { name: "Speech input" }); - const output = screen.getByRole("heading", { name: "Speech output" }); - expect( - input.compareDocumentPosition(output) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); - - it("hands an installed setup back to the initiating voice action exactly once", async () => { - const onUseSelected = vi.fn(); - const onOpenChange = vi.fn(); - renderWithProviders( - , - ); - - await userEvent.click( - screen.getByRole("button", { name: "Use selected voice" }), - ); - expect(onUseSelected).toHaveBeenCalledTimes(1); - expect(onOpenChange).not.toHaveBeenCalled(); - }); - - it("accepts an installed Siri voice without requiring Pocket TTS", async () => { - const onUseSelected = vi.fn(); - renderWithProviders( - , - ); - - await userEvent.click( - screen.getByRole("button", { name: "Use selected voice" }), - ); - expect(onUseSelected).toHaveBeenCalledTimes(1); - }); - it("keeps both missing model actions independently clickable", async () => { const installModel = vi.fn().mockResolvedValue(undefined); renderWithProviders( - , + , ); expect(screen.getByText(/173.8 MB download/)).toBeInTheDocument(); @@ -174,9 +76,7 @@ describe("PocketVoiceSetupDialog", () => { it("keeps one model's progress inline without a combined progress bar", () => { renderWithProviders( - { it("keeps an installed model removal actionable while the other model downloads", async () => { const removeModel = vi.fn().mockResolvedValue(undefined); renderWithProviders( - { it("shows a rapid second model click as queued with independent progress", () => { renderWithProviders( - { expect(screen.getByText("0.0 MB of 131.7 MB")).toBeInTheDocument(); }); - it("keeps the open setup surface mounted when installation completes", () => { - const onOpenChange = vi.fn(); + it("keeps the setup content mounted when installation completes", () => { const view = renderWithProviders( - , + , ); view.rerender( - { ); expect(screen.getByText("Pocket TTS")).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: "Voice conversation" }), - ).toBeInTheDocument(); expect(screen.getByText("Parakeet STT")).toBeInTheDocument(); expect(screen.getByText(/131.7 MB on disk/)).toBeInTheDocument(); - expect(onOpenChange).not.toHaveBeenCalled(); }); it("shows partial-cache disk usage and inline retry without hiding the other model", () => { renderWithProviders( - { name, })); renderWithProviders( - { it("confirms independent model removal", async () => { const removeModel = vi.fn().mockResolvedValue(undefined); renderWithProviders( - void; - onUseSelected?: () => void; - setup: PocketVoiceSetup; - siriSetup?: SiriVoiceSetup; - backend?: VoiceOutputBackend; - onBackendChange?: (backend: VoiceOutputBackend) => void; -}) { - const { t } = useTranslation("settings"); - const { status } = setup; - const siriSupported = getPlatform() === "mac"; - const ready = isVoiceSetupReady(status, siriSetup?.status ?? null, backend); - - return ( - - - - {t("voice.title")} - {t("voice.description")} - - -
-

{t("voice.speechInput")}

- -
-
-

{t("voice.speechOutput")}

- {siriSupported && siriSetup && onBackendChange ? ( -
- - -
- ) : null} - {backend === "siri" && siriSetup ? ( - - ) : ( - - )} -
-
- - {ready ? ( - - ) : ( - - )} - -
-
- ); -} - export function PocketVoiceSetupContent({ setup, presentation = "dialog", diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx index eb30be08f..6a2796686 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx @@ -17,7 +17,7 @@ function setup(overrides: Partial = {}): SiriVoiceSetup { return { status: { supported: true, - availableLanguages: ["en-US", "en-AU", "en-IN"], + availableLanguages: ["en-US", "en-AU", "en-IN", "en-IE"], selectedVoice: null, selectedVoiceInstalled: false, playbackSpeed: 1, @@ -31,7 +31,7 @@ function setup(overrides: Partial = {}): SiriVoiceSetup { ], }, language: "en-US", - languages: ["en-AU", "en-IN", "en-US"], + languages: ["en-AU", "en-IN", "en-IE", "en-US"], loading: false, error: null, downloadingVoiceKey: null, @@ -52,13 +52,35 @@ describe("SiriVoiceSettings", () => { await userEvent.click(screen.getByRole("combobox", { name: "Language" })); expect( - screen.getByRole("option", { name: "American English" }), + screen.getByRole("option", { name: "English (United States)" }), ).toBeInTheDocument(); expect( - screen.getByRole("option", { name: "Australian English" }), + screen.getByRole("option", { name: "English (Australia)" }), ).toBeInTheDocument(); - expect(screen.getByRole("option", { name: /India/ })).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "English (India)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "English (Ireland)" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: "American English" }), + ).toBeNull(); expect(screen.queryByRole("option", { name: "English" })).toBeNull(); + + await userEvent.click( + screen.getByRole("option", { name: "English (Australia)" }), + ); + expect(value.setLanguage).toHaveBeenCalledWith("en-AU"); + }); + + it("uses the same regional label for voice groups", () => { + const value = setup(); + renderWithProviders(); + + expect( + screen.getByRole("heading", { name: "English (United States)" }), + ).toBeInTheDocument(); }); it("previews a Siri voice before download", async () => { diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx index bfd9ba48d..66a768a8c 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx @@ -15,29 +15,20 @@ import { const PLAYBACK_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const; -function localeLabel(locale: string): string { +function localeLabel(locale: string, displayLocale?: string): string { try { return ( - new Intl.DisplayNames(undefined, { type: "language" }).of(locale) ?? - locale + new Intl.DisplayNames(displayLocale ? [displayLocale] : undefined, { + type: "language", + languageDisplay: "standard", + }).of(locale) ?? locale ); } catch { return locale; } } -function languageLabel(language: string): string { - try { - return ( - new Intl.DisplayNames(undefined, { type: "language" }).of(language) ?? - language - ); - } catch { - return language; - } -} - -function groupVoicesByLocale(voices: SiriVoice[]) { +function groupVoicesByLocale(voices: SiriVoice[], displayLocale?: string) { const groups = new Map(); for (const voice of voices) { groups.set(voice.language, [...(groups.get(voice.language) ?? []), voice]); @@ -48,7 +39,9 @@ function groupVoicesByLocale(voices: SiriVoice[]) { left.name.localeCompare(right.name), ), })).sort((left, right) => - localeLabel(left.locale).localeCompare(localeLabel(right.locale)), + localeLabel(left.locale, displayLocale).localeCompare( + localeLabel(right.locale, displayLocale), + ), ); } @@ -57,17 +50,20 @@ function formatBytes(bytes: number): string { } export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { - const { t } = useTranslation("settings"); + const { t, i18n } = useTranslation("settings"); + const displayLocale = i18n.resolvedLanguage ?? i18n.language; const languages = useMemo( () => [...setup.languages].sort((left, right) => - languageLabel(left).localeCompare(languageLabel(right)), + localeLabel(left, displayLocale).localeCompare( + localeLabel(right, displayLocale), + ), ), - [setup.languages], + [displayLocale, setup.languages], ); const groups = useMemo( - () => groupVoicesByLocale(setup.status?.voices ?? []), - [setup.status?.voices], + () => groupVoicesByLocale(setup.status?.voices ?? [], displayLocale), + [displayLocale, setup.status?.voices], ); const selectedKey = setup.status?.selectedVoice ? voiceKey(setup.status.selectedVoice) @@ -94,7 +90,7 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { {languages.map((language) => ( - {languageLabel(language)} + {localeLabel(language, displayLocale)} ))} @@ -147,7 +143,7 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { {groups.map((group) => (

- {localeLabel(group.locale)} + {localeLabel(group.locale, displayLocale)}

{group.voices.map((voice) => { @@ -248,4 +244,4 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { ); } -export { groupVoicesByLocale, languageLabel, localeLabel }; +export { groupVoicesByLocale, localeLabel }; diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index 3670750f7..b031cd6c8 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.test.tsx @@ -29,6 +29,46 @@ function setup(status: PocketVoiceStatus): PocketVoiceSetup { } describe("VoiceSettings", () => { + it("uses one accessible speech output heading for the backend picker", () => { + setupState.current = setup({ + statusRevision: 0, + installed: false, + pocketInstalled: false, + parakeetInstalled: false, + pocketSizeBytes: null, + parakeetSizeBytes: null, + pocketDownloadBytes: 0, + parakeetDownloadBytes: 0, + downloading: false, + activeModel: null, + pocketAttemptId: null, + parakeetAttemptId: null, + pocketProgress: null, + parakeetProgress: null, + pocketError: null, + parakeetError: null, + removing: null, + removalQueued: false, + downloadedBytes: 0, + totalBytes: 0, + error: null, + selectedVoice: "mary", + playbackSpeed: 1, + voices: [], + }); + renderWithProviders(); + + expect( + screen.getByRole("heading", { name: "Speech output" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Speech engine")).not.toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: "Speech output" }), + ).toHaveAccessibleDescription( + "Choose how Berd speaks assistant responses.", + ); + }); + it("keeps the Voice settings page open while Parakeet completes in place", () => { const missing: PocketVoiceStatus = { statusRevision: 4, diff --git a/src/features/voice-conversation/ui/VoiceSettings.tsx b/src/features/voice-conversation/ui/VoiceSettings.tsx index 8b87025d8..4f63272a3 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.tsx @@ -1,7 +1,7 @@ +import { useId } from "react"; import { useTranslation } from "react-i18next"; import { getPlatform } from "@/shared/lib/platform"; import { SettingsPage } from "@/shared/ui/SettingsPage"; -import { SettingsRow } from "@/shared/ui/settings-row"; import { Select, SelectContent, @@ -13,7 +13,7 @@ import { usePocketVoiceSetup } from "../hooks/usePocketVoiceSetup"; import { useSiriVoiceSetup } from "../hooks/useSiriVoiceSetup"; import type { VoiceOutputBackend } from "../lib/voiceOutputPreference"; import { useVoiceOutputPreference } from "../lib/voiceOutputPreference"; -import { PocketVoiceSetupContent } from "./PocketVoiceSetupDialog"; +import { PocketVoiceSetupContent } from "./PocketVoiceSetupContent"; import { SiriVoiceSettings } from "./SiriVoiceSettings"; export function VoiceSettings() { @@ -22,6 +22,8 @@ export function VoiceSettings() { const output = useVoiceOutputPreference(); const siriSetup = useSiriVoiceSetup(output.backend === "siri"); const siriSupported = getPlatform() === "mac"; + const outputHeadingId = useId(); + const outputDescriptionId = useId(); return (
-

{t("voice.speechOutput")}

- +
+

+ {t("voice.speechOutput")} +

+

+ {t("voice.outputBackendDescription")} +

+
+
- } - /> +
+ {output.backend === "siri" ? ( ) : ( diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 7505c73e8..7a34bcac2 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -867,7 +867,6 @@ "voice": { "backendPocket": "Pocket TTS", "backendSiri": "Siri voices (macOS)", - "description": "Install speech recognition and choose how Berd speaks during Voice Conversation.", "download": "Download model", "downloadVoice": "Download {{voice}}", "downloadingVoice": "Downloading {{voice}}", @@ -884,8 +883,6 @@ "modelInstalledSize": "Installed · {{size}} on disk", "modelMissingSize": "Not installed · {{size}} download", "modelNotInstalled": "Not installed", - "notNow": "Not now", - "outputBackend": "Speech engine", "outputBackendDescription": "Choose how Berd speaks assistant responses.", "playbackSpeed": "Playback speed", "playing": "Playing", @@ -911,9 +908,7 @@ "siriUseVoice": "Use voice", "speechInput": "Speech input", "speechOutput": "Speech output", - "title": "Voice conversation", "selectedVoice": "Selected voice: {{voice}}", - "useSelected": "Use selected voice", "useVoice": "Use {{voice}}", "voiceLabel": "Pocket TTS voice" } diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index e56e4fd53..5fe6386a4 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -870,7 +870,6 @@ "voice": { "backendPocket": "Pocket TTS", "backendSiri": "Voces de Siri (macOS)", - "description": "Instala el reconocimiento de voz y elige cómo habla Berd durante la conversación por voz.", "download": "Descargar modelo", "downloadVoice": "Descargar {{voice}}", "downloadingVoice": "Descargando {{voice}}", @@ -887,8 +886,6 @@ "modelInstalledSize": "Instalado · {{size}} en disco", "modelMissingSize": "No instalado · descarga de {{size}}", "modelNotInstalled": "No instalado", - "notNow": "Ahora no", - "outputBackend": "Motor de voz", "outputBackendDescription": "Elige cómo Berd reproduce las respuestas del asistente.", "playbackSpeed": "Velocidad de reproducción", "playing": "Reproduciendo", @@ -914,9 +911,7 @@ "siriUseVoice": "Usar voz", "speechInput": "Entrada de voz", "speechOutput": "Salida de voz", - "title": "Conversación por voz", "selectedVoice": "Voz seleccionada: {{voice}}", - "useSelected": "Usar la voz seleccionada", "useVoice": "Usar {{voice}}", "voiceLabel": "Voz de Pocket TTS" } From b8e35b5ab5d87f8e2f3afe98da095e3799b2db59 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 21 Aug 2026 19:48:04 -0400 Subject: [PATCH 2/9] fix(voice): preserve setup start intent --- src/app/AppShell.navigation.test.tsx | 39 ++++++++++++ src/app/AppShell.tsx | 66 ++++++++++++++++++--- src/features/chat/ui/ChatView.tsx | 9 ++- src/features/settings/lib/settingsEvents.ts | 7 ++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 095c4cb48..30fef2907 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -26,6 +26,7 @@ import type { Message } from "@/shared/types/messages"; import type { GitState } from "@/shared/types/git"; import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; +import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry"; import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; @@ -1054,6 +1055,7 @@ describe("AppShell global navigation", () => { activeWorkspaceBySession: {}, archiveMutationBySessionId: {}, }); + useVoiceConversationStore.setState({ requestedStartSessionId: null }); useAgentStore.setState({ selectedProvider: "goose", }); @@ -4080,6 +4082,43 @@ describe("AppShell global navigation", () => { ).not.toBeInTheDocument(); }); + it("returns from voice setup to its session and cancels an unready start", async () => { + const user = userEvent.setup(); + const session = useChatSessionStore.getState().createDraftSession({ + title: "Voice setup target", + workingDir: "/tmp/voice-setup-target", + }); + useVoiceConversationStore.getState().requestStart(session.id); + renderAppShell(); + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { + type: "voice-setup", + sessionId: session.id, + }, + }, + }), + ); + }); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe(session.id); + expect( + useVoiceConversationStore.getState().requestedStartSessionId, + ).toBeNull(); + }); + it("discarding a dirty agent draft continues the pending navigation", async () => { const user = userEvent.setup(); renderAppShell(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 0975f4744..c7b17253e 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -34,6 +34,7 @@ import { requestOpenSettings, type AgentBuilderProviderSetupReturnTarget, type OpenSettingsEventDetail, + type VoiceSetupReturnTarget, } from "@/features/settings/lib/settingsEvents"; import type { ExtensionEntry } from "@/features/extensions/types"; import { acceptFirstSend } from "@/features/chat/lib/firstWorkspaceSend"; @@ -886,6 +887,8 @@ export function AppShell({ agentBuilderSettingsReturnTarget, setAgentBuilderSettingsReturnTarget, ] = useState(null); + const [voiceSettingsReturnTarget, setVoiceSettingsReturnTarget] = + useState(null); const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), ); @@ -3203,11 +3206,6 @@ export function AppShell({ const handleGlobalVoiceConversationStart = useCallback( (payload: GlobalComposerExpandPayload): Promise => { if (!capabilities.voiceConversation) return Promise.resolve(false); - if (!globalVoiceReady) { - requestOpenSettings("voice"); - return Promise.resolve(false); - } - const options = payload.options; const project = options?.projectId ? projects.find((candidate) => candidate.id === options.projectId) @@ -3262,8 +3260,15 @@ export function AppShell({ chatState.setDraft(sessionId, payload.text); chatState.setSkillDrafts(sessionId, payload.selectedSkills); chatState.setDraftAttachments(sessionId, options?.attachments ?? []); - handleNavigateToSession(sessionId); requestVoiceConversationStart(sessionId); + if (!globalVoiceReady) { + requestOpenSettings("voice", { + returnTarget: { type: "voice-setup", sessionId }, + }); + resetGlobalComposerTransition(); + return true; + } + handleNavigateToSession(sessionId); resetGlobalComposerTransition(); return true; }; @@ -3436,6 +3441,40 @@ export function AppShell({ setChatActiveSession, ]); + const returnToVoiceSettingsTarget = useCallback(() => { + const target = voiceSettingsReturnTarget; + if (!target) { + return false; + } + + const session = useChatSessionStore.getState().getSession(target.sessionId); + setVoiceSettingsReturnTarget(null); + if (!session || session.archivedAt) { + useVoiceConversationStore + .getState() + .clearRequestedStart(target.sessionId); + return false; + } + if (!globalVoiceReady) { + useVoiceConversationStore + .getState() + .clearRequestedStart(target.sessionId); + } + + clearSettingsSectionUrl(); + setActiveSession(target.sessionId); + setActiveView("chat"); + setChatActiveSession(target.sessionId); + useChatStore.getState().markSessionRead(target.sessionId); + void loadSessionMessagesAndPrepare(target.sessionId); + return true; + }, [ + globalVoiceReady, + setActiveSession, + setChatActiveSession, + voiceSettingsReturnTarget, + ]); + const openSettings = useCallback( (section: SectionId = DEFAULT_SETTINGS_SECTION) => { const enabledSection = resolveEnabledSettingsSection( @@ -3456,12 +3495,15 @@ export function AppShell({ ); const leaveSecondarySurface = useCallback(() => { + if (returnToVoiceSettingsTarget()) { + return; + } if (returnToAgentBuilderSettingsTarget()) { return; } clearSettingsSectionUrl(); setActiveView(lastNonSecondaryViewRef.current); - }, [returnToAgentBuilderSettingsTarget]); + }, [returnToAgentBuilderSettingsTarget, returnToVoiceSettingsTarget]); const selectSettingsSection = useCallback( (section: SectionId) => { @@ -3510,6 +3552,11 @@ export function AppShell({ ? detail.returnTarget : null, ); + setVoiceSettingsReturnTarget( + detail?.returnTarget?.type === "voice-setup" + ? detail.returnTarget + : null, + ); openSettings(resolveSettingsSection(section ?? null)); }; @@ -4193,6 +4240,10 @@ export function AppShell({ ); const goBack = useCallback(() => { + if (activeView === "settings" && returnToVoiceSettingsTarget()) { + updateNavigationAvailability(); + return; + } if (activeView === "settings" && agentBuilderSettingsReturnTarget) { const history = navigationHistoryRef.current; const previousLocation = @@ -4227,6 +4278,7 @@ export function AppShell({ applyNavigationLocation, guardAppNavigation, returnToAgentBuilderSettingsTarget, + returnToVoiceSettingsTarget, updateNavigationAvailability, ]); diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 2bb4175ee..a5a4a126f 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -76,6 +76,7 @@ import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voic import { isVoiceSetupReady } from "@/features/voice-conversation/lib/voiceSetupReadiness"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; +import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { SecurityConfirmationPanel, useHasPendingSecurityConfirmation, @@ -242,6 +243,9 @@ export function ChatView({ siriVoiceSetup.status, voiceOutput.backend, ); + const requestVoiceConversationStart = useVoiceConversationStore( + (state) => state.requestStart, + ); const voiceConversation = useVoiceConversationController({ sessionId, // Voice delivery only needs to wait for admission. Holding its per-session @@ -252,7 +256,10 @@ export function ChatView({ isGooseSession: controller.selectedProvider === "goose", pocketReady: voiceReady, onPocketSetupRequired: () => { - requestOpenSettings("voice"); + requestVoiceConversationStart(sessionId); + requestOpenSettings("voice", { + returnTarget: { type: "voice-setup", sessionId }, + }); }, readOnly: Boolean(readOnlyStatus), disabled: diff --git a/src/features/settings/lib/settingsEvents.ts b/src/features/settings/lib/settingsEvents.ts index 5b3247250..e4f703621 100644 --- a/src/features/settings/lib/settingsEvents.ts +++ b/src/features/settings/lib/settingsEvents.ts @@ -6,9 +6,14 @@ export interface AgentBuilderProviderSetupReturnTarget { providerId: string; } +export interface VoiceSetupReturnTarget { + type: "voice-setup"; + sessionId: string; +} + export interface OpenSettingsEventDetail { section?: string; - returnTarget?: AgentBuilderProviderSetupReturnTarget; + returnTarget?: AgentBuilderProviderSetupReturnTarget | VoiceSetupReturnTarget; } export function requestOpenSettings( From 9e94572c6940674b699a89d73cb12b58f15eca3d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 21 Aug 2026 19:55:35 -0400 Subject: [PATCH 3/9] fix(voice): reconcile setup navigation history --- src/app/AppShell.navigation.test.tsx | 40 ++++++++++++++++++++++++++++ src/app/AppShell.tsx | 27 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 30fef2907..d1ada8d03 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -4117,6 +4117,46 @@ describe("AppShell global navigation", () => { expect( useVoiceConversationStore.getState().requestedStartSessionId, ).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Forward" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + }); + + it("cancels a voice start when navigating away from setup", async () => { + const user = userEvent.setup(); + const session = useChatSessionStore.getState().createDraftSession({ + title: "Voice setup target", + workingDir: "/tmp/voice-setup-target", + }); + useVoiceConversationStore.getState().requestStart(session.id); + renderAppShell(); + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { + type: "voice-setup", + sessionId: session.id, + }, + }, + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + + await user.click(screen.getByRole("button", { name: "Sidebar skills" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + expect( + useVoiceConversationStore.getState().requestedStartSessionId, + ).toBeNull(); }); it("discarding a dirty agent draft continues the pending navigation", async () => { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index c7b17253e..dba1a1d25 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -3455,6 +3455,21 @@ export function AppShell({ .clearRequestedStart(target.sessionId); return false; } + + const history = navigationHistoryRef.current; + const previousLocation = + history.index > 0 ? history.entries[history.index - 1] : null; + if ( + previousLocation?.view === "chat" && + previousLocation.sessionId === target.sessionId + ) { + history.index -= 1; + } else { + history.entries.splice(history.index, 0, { + view: "chat", + sessionId: target.sessionId, + }); + } if (!globalVoiceReady) { useVoiceConversationStore .getState() @@ -3467,14 +3482,26 @@ export function AppShell({ setChatActiveSession(target.sessionId); useChatStore.getState().markSessionRead(target.sessionId); void loadSessionMessagesAndPrepare(target.sessionId); + updateNavigationAvailability(); return true; }, [ globalVoiceReady, setActiveSession, setChatActiveSession, + updateNavigationAvailability, voiceSettingsReturnTarget, ]); + useEffect(() => { + if (activeView === "settings" || !voiceSettingsReturnTarget) { + return; + } + useVoiceConversationStore + .getState() + .clearRequestedStart(voiceSettingsReturnTarget.sessionId); + setVoiceSettingsReturnTarget(null); + }, [activeView, voiceSettingsReturnTarget]); + const openSettings = useCallback( (section: SectionId = DEFAULT_SETTINGS_SECTION) => { const enabledSection = resolveEnabledSettingsSection( From d84d43fc4d6aacd3474b07bcca7b977b14541305 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 13:07:42 -0400 Subject: [PATCH 4/9] fix(voice): clarify setup readiness --- .../ui/SiriVoiceSettings.test.tsx | 11 +- .../ui/SiriVoiceSettings.tsx | 124 ++++++++++-------- .../ui/VoiceSettings.test.tsx | 97 +++++++++++++- .../voice-conversation/ui/VoiceSettings.tsx | 43 ++++++ src/shared/i18n/locales/en/settings.json | 12 +- src/shared/i18n/locales/es/settings.json | 12 +- 6 files changed, 230 insertions(+), 69 deletions(-) diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx index 6a2796686..a36341595 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx @@ -98,7 +98,7 @@ describe("SiriVoiceSettings", () => { ).toBeInTheDocument(); }); - it("gives each voice action a voice-specific accessible name", () => { + it("selects installed voices from a compact, accessible row", async () => { const status = setup().status; expect(status).not.toBeNull(); if (!status) return; @@ -132,6 +132,15 @@ describe("SiriVoiceSettings", () => { expect( screen.getByRole("button", { name: "Download Quinn" }), ).toBeInTheDocument(); + expect(screen.getByText("Using 0.0 MB")).toBeInTheDocument(); + expect(screen.getByText("310.5 MB")).toBeInTheDocument(); + expect(screen.queryByText("Use voice")).not.toBeInTheDocument(); + expect(screen.queryByText("Download model")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Use Aaron" })); + expect(value.selectVoice).toHaveBeenCalledWith( + expect.objectContaining({ name: "Aaron", installed: true }), + ); }); it("exposes preview and download progress in accessible names", () => { diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx index 66a768a8c..9f389217e 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx @@ -1,4 +1,4 @@ -import { Check, Download, Play } from "lucide-react"; +import { Check, CloudDownload, Play } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { SiriVoice } from "../api/siriVoice"; @@ -151,72 +151,59 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { const selected = key === selectedKey; const downloading = setup.downloadingVoiceKey === key; const previewing = setup.previewingVoiceKey === key; + const voiceDetails = ( + + + {voice.name} + + + {voice.installed + ? t("voice.siriUsingSize", { + size: formatBytes(voice.sizeBytes), + }) + : formatBytes(voice.sizeBytes)} + + + ); return (
-
-
- {voice.name} - {selected ? ( - - ) : null} -
-

- {voice.installed - ? t("voice.siriInstalled") - : t("voice.siriDownloadSize", { - size: formatBytes(voice.sizeBytes), - })} -

-
- {voice.installed ? ( ) : ( +
{voiceDetails}
+ )} + {selected ? ( +
); })} diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index b031cd6c8..2fc5f5980 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.test.tsx @@ -1,17 +1,36 @@ import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/test/render"; import type { PocketVoiceStatus } from "../api/pocketVoice"; import type { PocketVoiceSetup } from "../hooks/usePocketVoiceSetup"; +import type { SiriVoiceSetup } from "../hooks/useSiriVoiceSetup"; +import type { VoiceOutputBackend } from "../lib/voiceOutputPreference"; import { VoiceSettings } from "./VoiceSettings"; const setupState = vi.hoisted(() => ({ current: null as PocketVoiceSetup | null, })); +const siriSetupState = vi.hoisted(() => ({ + current: null as SiriVoiceSetup | null, +})); +const outputState = vi.hoisted(() => ({ + backend: "pocket" as VoiceOutputBackend, +})); vi.mock("../hooks/usePocketVoiceSetup", () => ({ usePocketVoiceSetup: () => setupState.current, })); +vi.mock("../hooks/useSiriVoiceSetup", () => ({ + useSiriVoiceSetup: () => siriSetupState.current, + voiceKey: (voice: { name: string; language: string }) => + `${voice.name.toLowerCase()}|${voice.language.toLowerCase()}`, +})); +vi.mock("../lib/voiceOutputPreference", () => ({ + useVoiceOutputPreference: () => ({ + backend: outputState.backend, + setBackend: vi.fn(), + }), +})); function setup(status: PocketVoiceStatus): PocketVoiceSetup { return { @@ -28,7 +47,43 @@ function setup(status: PocketVoiceStatus): PocketVoiceSetup { }; } +function siriSetup(): SiriVoiceSetup { + return { + status: { + supported: true, + availableLanguages: ["en-US"], + selectedVoice: { name: "Nora", language: "en-US" }, + selectedVoiceInstalled: true, + playbackSpeed: 1, + voices: [ + { + name: "Nora", + language: "en-US", + sizeBytes: 0, + installed: true, + }, + ], + }, + language: "en-US", + languages: ["en-US"], + loading: false, + error: null, + downloadingVoiceKey: null, + previewingVoiceKey: null, + setLanguage: vi.fn(), + setPlaybackSpeed: vi.fn(), + downloadVoice: vi.fn(), + previewVoice: vi.fn(), + selectVoice: vi.fn(), + }; +} + describe("VoiceSettings", () => { + beforeEach(() => { + outputState.backend = "pocket"; + siriSetupState.current = siriSetup(); + }); + it("uses one accessible speech output heading for the backend picker", () => { setupState.current = setup({ statusRevision: 0, @@ -140,4 +195,44 @@ describe("VoiceSettings", () => { expect(screen.getByText(/131.7 MB on disk/)).toBeInTheDocument(); expect(screen.queryByText("Preparing model")).not.toBeInTheDocument(); }); + + it("explains when missing speech input blocks Voice Conversation", () => { + outputState.backend = "siri"; + siriSetupState.current = siriSetup(); + setupState.current = setup({ + statusRevision: 0, + installed: false, + pocketInstalled: false, + parakeetInstalled: false, + pocketSizeBytes: null, + parakeetSizeBytes: null, + pocketDownloadBytes: 0, + parakeetDownloadBytes: 104_337_827, + downloading: false, + activeModel: null, + pocketAttemptId: null, + parakeetAttemptId: null, + pocketProgress: null, + parakeetProgress: null, + pocketError: null, + parakeetError: null, + removing: null, + removalQueued: false, + downloadedBytes: 0, + totalBytes: 0, + error: null, + selectedVoice: "mary", + playbackSpeed: 1, + voices: [], + }); + + renderWithProviders(); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Voice Conversation isn't ready", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Parakeet STT is not installed. Download it below to use Voice Conversation.", + ); + }); }); diff --git a/src/features/voice-conversation/ui/VoiceSettings.tsx b/src/features/voice-conversation/ui/VoiceSettings.tsx index 4f63272a3..ff4cdb0ed 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.tsx @@ -1,7 +1,9 @@ +import { CircleAlert } from "lucide-react"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import { getPlatform } from "@/shared/lib/platform"; import { SettingsPage } from "@/shared/ui/SettingsPage"; +import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Select, SelectContent, @@ -16,6 +18,23 @@ import { useVoiceOutputPreference } from "../lib/voiceOutputPreference"; import { PocketVoiceSetupContent } from "./PocketVoiceSetupContent"; import { SiriVoiceSettings } from "./SiriVoiceSettings"; +function readinessDescriptionKey( + inputReady: boolean, + outputReady: boolean, + backend: VoiceOutputBackend, +): string | null { + if (inputReady && outputReady) return null; + if (!inputReady && !outputReady) { + return backend === "siri" + ? "voice.notReadyInputAndSiriOutput" + : "voice.notReadyInputAndPocketOutput"; + } + if (!inputReady) return "voice.notReadyInput"; + return backend === "siri" + ? "voice.notReadySiriOutput" + : "voice.notReadyPocketOutput"; +} + export function VoiceSettings() { const { t } = useTranslation("settings"); const setup = usePocketVoiceSetup(); @@ -24,6 +43,23 @@ export function VoiceSettings() { const siriSupported = getPlatform() === "mac"; const outputHeadingId = useId(); const outputDescriptionId = useId(); + const inputReady = setup.status?.parakeetInstalled ?? false; + const outputReady = + output.backend === "siri" + ? Boolean( + siriSetup.status?.supported && + siriSetup.status.selectedVoice && + siriSetup.status.selectedVoiceInstalled, + ) + : (setup.status?.pocketInstalled ?? false); + const readinessLoaded = + setup.status !== null && + (output.backend === "pocket" || + siriSetup.status !== null || + siriSetup.error !== null); + const readinessKey = readinessLoaded + ? readinessDescriptionKey(inputReady, outputReady, output.backend) + : null; return ( + {readinessKey ? ( + + + {t("voice.notReadyTitle")} + {t(readinessKey)} + + ) : null}

{t("voice.speechInput")}

Date: Sat, 22 Aug 2026 13:42:47 -0400 Subject: [PATCH 5/9] fix(voice): harden settings setup flow --- src/app/AppShell.navigation.test.tsx | 106 ++++++++++++++++++ src/app/AppShell.tsx | 27 ++++- .../ui/PocketVoiceSetupContent.tsx | 22 +--- .../ui/SiriVoiceSettings.test.tsx | 2 +- .../ui/SiriVoiceSettings.tsx | 4 +- .../ui/VoiceSettings.test.tsx | 97 +++++++++++++++- .../voice-conversation/ui/VoiceSettings.tsx | 25 ++--- src/shared/i18n/locales/en/settings.json | 1 - src/shared/i18n/locales/es/settings.json | 1 - 9 files changed, 239 insertions(+), 46 deletions(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index d1ada8d03..8bcf5ab7d 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -106,6 +106,29 @@ const mockAfterNextPaint = vi.hoisted(() => ({ })); const mockSessionWindowSupport = vi.hoisted(() => ({ supported: false })); const mockFocusSessionWindow = vi.hoisted(() => vi.fn()); +const mockVoiceSetupReadiness = vi.hoisted(() => ({ ready: false })); +const mockVoiceSettingsEnabled = vi.hoisted(() => ({ enabled: false })); + +vi.mock("@/features/settings/ui/settingsSections", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/features/settings/ui/settingsSections") + >(); + return { + ...actual, + resolveEnabledSettingsSection: ( + section: Parameters[0], + capabilities: Parameters[1], + ) => + section === "voice" && mockVoiceSettingsEnabled.enabled + ? "voice" + : actual.resolveEnabledSettingsSection(section, capabilities), + }; +}); + +vi.mock("@/features/voice-conversation/lib/voiceSetupReadiness", () => ({ + isVoiceSetupReady: () => mockVoiceSetupReadiness.ready, +})); function deferred() { let resolve!: (value: T) => void; @@ -933,6 +956,8 @@ describe("AppShell global navigation", () => { useShortcutsDialogStore.setState({ open: false }); document.documentElement.removeAttribute("data-global-composer-visible"); mockSessionWindowSupport.supported = false; + mockVoiceSetupReadiness.ready = false; + mockVoiceSettingsEnabled.enabled = false; mockFocusSessionWindow.mockReset(); useSessionWindowStore.getState().setSnapshot([]); mockListExtensions.mockReset(); @@ -4089,6 +4114,7 @@ describe("AppShell global navigation", () => { workingDir: "/tmp/voice-setup-target", }); useVoiceConversationStore.getState().requestStart(session.id); + mockVoiceSettingsEnabled.enabled = true; renderAppShell(); act(() => { @@ -4131,6 +4157,7 @@ describe("AppShell global navigation", () => { workingDir: "/tmp/voice-setup-target", }); useVoiceConversationStore.getState().requestStart(session.id); + mockVoiceSettingsEnabled.enabled = true; renderAppShell(); act(() => { @@ -4159,6 +4186,85 @@ describe("AppShell global navigation", () => { ).toBeNull(); }); + it("cancels a voice start when another settings section replaces Voice setup", async () => { + const user = userEvent.setup(); + const session = useChatSessionStore.getState().createDraftSession({ + title: "Voice setup target", + workingDir: "/tmp/voice-setup-target", + }); + useVoiceConversationStore.getState().requestStart(session.id); + mockVoiceSettingsEnabled.enabled = true; + renderAppShell(); + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { + type: "voice-setup", + sessionId: session.id, + }, + }, + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + + await user.click(screen.getByRole("button", { name: "Sidebar providers" })); + + await waitFor(() => { + expect(screen.getByTestId("settings-section")).toHaveTextContent( + "providers", + ); + }); + expect( + useVoiceConversationStore.getState().requestedStartSessionId, + ).toBeNull(); + }); + + it("preserves a ready voice start when returning from setup", async () => { + const user = userEvent.setup(); + const session = useChatSessionStore.getState().createDraftSession({ + title: "Voice setup target", + workingDir: "/tmp/voice-setup-target", + }); + useVoiceConversationStore.getState().requestStart(session.id); + mockVoiceSettingsEnabled.enabled = true; + const view = renderAppShell(); + + act(() => { + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { + type: "voice-setup", + sessionId: session.id, + }, + }, + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + + mockVoiceSetupReadiness.ready = true; + view.rerender(appShellWithTheme()); + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe(session.id); + expect(useVoiceConversationStore.getState().requestedStartSessionId).toBe( + session.id, + ); + }); + it("discarding a dirty agent draft continues the pending navigation", async () => { const user = userEvent.setup(); renderAppShell(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index dba1a1d25..60a805de4 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -889,6 +889,8 @@ export function AppShell({ ] = useState(null); const [voiceSettingsReturnTarget, setVoiceSettingsReturnTarget] = useState(null); + const voiceSettingsReturnTargetRef = useRef(voiceSettingsReturnTarget); + voiceSettingsReturnTargetRef.current = voiceSettingsReturnTarget; const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), ); @@ -3448,6 +3450,7 @@ export function AppShell({ } const session = useChatSessionStore.getState().getSession(target.sessionId); + voiceSettingsReturnTargetRef.current = null; setVoiceSettingsReturnTarget(null); if (!session || session.archivedAt) { useVoiceConversationStore @@ -3493,14 +3496,18 @@ export function AppShell({ ]); useEffect(() => { - if (activeView === "settings" || !voiceSettingsReturnTarget) { + if ( + !voiceSettingsReturnTarget || + (activeView === "settings" && activeSettingsSection === "voice") + ) { return; } useVoiceConversationStore .getState() .clearRequestedStart(voiceSettingsReturnTarget.sessionId); + voiceSettingsReturnTargetRef.current = null; setVoiceSettingsReturnTarget(null); - }, [activeView, voiceSettingsReturnTarget]); + }, [activeSettingsSection, activeView, voiceSettingsReturnTarget]); const openSettings = useCallback( (section: SectionId = DEFAULT_SETTINGS_SECTION) => { @@ -3579,11 +3586,21 @@ export function AppShell({ ? detail.returnTarget : null, ); - setVoiceSettingsReturnTarget( + const currentVoiceTarget = voiceSettingsReturnTargetRef.current; + const nextVoiceTarget = detail?.returnTarget?.type === "voice-setup" ? detail.returnTarget - : null, - ); + : null; + if ( + currentVoiceTarget && + currentVoiceTarget.sessionId !== nextVoiceTarget?.sessionId + ) { + useVoiceConversationStore + .getState() + .clearRequestedStart(currentVoiceTarget.sessionId); + } + voiceSettingsReturnTargetRef.current = nextVoiceTarget; + setVoiceSettingsReturnTarget(nextVoiceTarget); openSettings(resolveSettingsSection(section ?? null)); }; diff --git a/src/features/voice-conversation/ui/PocketVoiceSetupContent.tsx b/src/features/voice-conversation/ui/PocketVoiceSetupContent.tsx index 528e825e7..df9cbecd7 100644 --- a/src/features/voice-conversation/ui/PocketVoiceSetupContent.tsx +++ b/src/features/voice-conversation/ui/PocketVoiceSetupContent.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; import { SettingsRow } from "@/shared/ui/settings-row"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; -import { cn } from "@/shared/lib/cn"; import { Progress } from "@/shared/ui/progress"; import { RadioGroup, RadioGroupItem } from "@/shared/ui/radio-group"; import type { VoiceModelKind } from "../api/pocketVoice"; @@ -16,12 +15,10 @@ function formatBytes(bytes: number): string { export function PocketVoiceSetupContent({ setup, - presentation = "dialog", models: visibleModels, showPocketVoiceControls = true, }: { setup: PocketVoiceSetup; - presentation?: "dialog" | "settings"; models?: VoiceModelKind[]; showPocketVoiceControls?: boolean; }) { @@ -38,7 +35,6 @@ export function PocketVoiceSetupContent({ const pocketInstalled = status?.pocketInstalled ?? status?.installed ?? false; const parakeetInstalled = status?.parakeetInstalled ?? status?.installed ?? false; - const isSettingsPresentation = presentation === "settings"; const models = [ { model: "pocket" as const, @@ -71,16 +67,8 @@ export function PocketVoiceSetupContent({ ].filter(({ model }) => !visibleModels || visibleModels.includes(model)); return ( -
-
+
+
{models.map( ({ model, @@ -95,10 +83,6 @@ export function PocketVoiceSetupContent({ {error || (showPocketVoiceControls && status && pocketInstalled) ? ( -
+
{error ? (

{error} diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx index a36341595..5d17c1fda 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx @@ -132,7 +132,7 @@ describe("SiriVoiceSettings", () => { expect( screen.getByRole("button", { name: "Download Quinn" }), ).toBeInTheDocument(); - expect(screen.getByText("Using 0.0 MB")).toBeInTheDocument(); + expect(screen.getByText("Installed · 0.0 MB on disk")).toBeInTheDocument(); expect(screen.getByText("310.5 MB")).toBeInTheDocument(); expect(screen.queryByText("Use voice")).not.toBeInTheDocument(); expect(screen.queryByText("Download model")).not.toBeInTheDocument(); diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx index 9f389217e..e22ba7220 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx @@ -158,7 +158,7 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { {voice.installed - ? t("voice.siriUsingSize", { + ? t("voice.modelInstalledSize", { size: formatBytes(voice.sizeBytes), }) : formatBytes(voice.sizeBytes)} @@ -253,5 +253,3 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) {

); } - -export { groupVoicesByLocale, localeLabel }; diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index 2fc5f5980..5da70ca6a 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.test.tsx @@ -47,6 +47,38 @@ function setup(status: PocketVoiceStatus): PocketVoiceSetup { }; } +function pocketStatus( + overrides: Partial = {}, +): PocketVoiceStatus { + return { + statusRevision: 0, + installed: false, + pocketInstalled: false, + parakeetInstalled: false, + pocketSizeBytes: null, + parakeetSizeBytes: null, + pocketDownloadBytes: 0, + parakeetDownloadBytes: 104_337_827, + downloading: false, + activeModel: null, + pocketAttemptId: null, + parakeetAttemptId: null, + pocketProgress: null, + parakeetProgress: null, + pocketError: null, + parakeetError: null, + removing: null, + removalQueued: false, + downloadedBytes: 0, + totalBytes: 0, + error: null, + selectedVoice: "mary", + playbackSpeed: 1, + voices: [], + ...overrides, + }; +} + function siriSetup(): SiriVoiceSetup { return { status: { @@ -231,8 +263,69 @@ describe("VoiceSettings", () => { expect(screen.getByRole("alert")).toHaveTextContent( "Voice Conversation isn't ready", ); - expect(screen.getByRole("alert")).toHaveTextContent( - "Parakeet STT is not installed. Download it below to use Voice Conversation.", + expect( + screen.getByText( + "Parakeet STT is not installed. Download it below to use Voice Conversation.", + ), + ).toBeInTheDocument(); + }); + + it("does not diagnose a Siri load failure as a missing selection", () => { + outputState.backend = "siri"; + const staleSiriSetup = siriSetup(); + siriSetupState.current = { + ...staleSiriSetup, + status: staleSiriSetup.status + ? { + ...staleSiriSetup.status, + selectedVoice: null, + selectedVoiceInstalled: false, + } + : null, + error: "Siri voice catalog unavailable", + }; + setupState.current = setup( + pocketStatus({ + installed: true, + parakeetInstalled: true, + parakeetSizeBytes: 131_662_414, + parakeetDownloadBytes: 0, + }), ); + + renderWithProviders(); + + expect( + screen.getByText("Siri voice catalog unavailable"), + ).toBeInTheDocument(); + expect( + screen.queryByText(/No installed Siri voice is selected/), + ).not.toBeInTheDocument(); + }); + + it("still explains missing speech input while Siri status is unavailable", () => { + outputState.backend = "siri"; + siriSetupState.current = { + ...siriSetup(), + status: null, + error: "Siri voice catalog unavailable", + }; + setupState.current = setup( + pocketStatus({ + installed: false, + parakeetInstalled: false, + }), + ); + + renderWithProviders(); + + expect( + screen.getByText( + "Parakeet STT is not installed. Download it below to use Voice Conversation.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText(/No installed Siri voice is selected/), + ).not.toBeInTheDocument(); }); }); diff --git a/src/features/voice-conversation/ui/VoiceSettings.tsx b/src/features/voice-conversation/ui/VoiceSettings.tsx index ff4cdb0ed..7b0da897f 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.tsx @@ -52,14 +52,16 @@ export function VoiceSettings() { siriSetup.status.selectedVoiceInstalled, ) : (setup.status?.pocketInstalled ?? false); - const readinessLoaded = - setup.status !== null && - (output.backend === "pocket" || - siriSetup.status !== null || - siriSetup.error !== null); - const readinessKey = readinessLoaded - ? readinessDescriptionKey(inputReady, outputReady, output.backend) - : null; + const siriOutputLoaded = + siriSetup.status !== null && siriSetup.error === null; + const readinessKey = + setup.status === null + ? null + : !inputReady && output.backend === "siri" && !siriOutputLoaded + ? "voice.notReadyInput" + : output.backend === "siri" && !siriOutputLoaded + ? null + : readinessDescriptionKey(inputReady, outputReady, output.backend); return ( {t("voice.speechInput")} @@ -123,11 +124,7 @@ export function VoiceSettings() { {output.backend === "siri" ? ( ) : ( - + )}
diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index aa7f350d3..31a060b0e 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -907,7 +907,6 @@ "siriLoading": "Loading Siri voices…", "siriNoVoices": "No Siri voices are available for this language.", "siriUnsupported": "Siri voices are available on macOS only.", - "siriUsingSize": "Using {{size}}", "speechInput": "Speech input", "speechOutput": "Speech output", "selectedVoice": "Selected voice: {{voice}}", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 7f4db95b8..dc4a7029f 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -910,7 +910,6 @@ "siriLoading": "Cargando voces de Siri…", "siriNoVoices": "No hay voces de Siri disponibles para este idioma.", "siriUnsupported": "Las voces de Siri solo están disponibles en macOS.", - "siriUsingSize": "Usando {{size}}", "speechInput": "Entrada de voz", "speechOutput": "Salida de voz", "selectedVoice": "Voz seleccionada: {{voice}}", From 3172b12fbafeee11e4009bf83e159b8c52b7bbf1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 17:45:51 -0400 Subject: [PATCH 6/9] fix(voice): address settings review feedback --- src/app/AppShell.navigation.test.tsx | 125 +++++++++++++++- src/app/AppShell.tsx | 139 ++++++++++++------ .../lib/voiceSetupReadiness.test.ts | 43 +++++- .../lib/voiceSetupReadiness.ts | 18 ++- .../ui/SiriVoiceSettings.test.tsx | 62 ++++++++ .../ui/SiriVoiceSettings.tsx | 25 +++- .../ui/VoiceSettings.test.tsx | 8 + .../voice-conversation/ui/VoiceSettings.tsx | 7 +- 8 files changed, 363 insertions(+), 64 deletions(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 8bcf5ab7d..fbb80f973 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -106,7 +106,11 @@ const mockAfterNextPaint = vi.hoisted(() => ({ })); const mockSessionWindowSupport = vi.hoisted(() => ({ supported: false })); const mockFocusSessionWindow = vi.hoisted(() => vi.fn()); -const mockVoiceSetupReadiness = vi.hoisted(() => ({ ready: false })); +const mockVoiceSetupReadiness = vi.hoisted(() => ({ + ready: false, + authoritativeReady: false, + refreshPromise: null as Promise | null, +})); const mockVoiceSettingsEnabled = vi.hoisted(() => ({ enabled: false })); vi.mock("@/features/settings/ui/settingsSections", async (importOriginal) => { @@ -128,6 +132,9 @@ vi.mock("@/features/settings/ui/settingsSections", async (importOriginal) => { vi.mock("@/features/voice-conversation/lib/voiceSetupReadiness", () => ({ isVoiceSetupReady: () => mockVoiceSetupReadiness.ready, + refreshVoiceSetupReadiness: () => + mockVoiceSetupReadiness.refreshPromise ?? + Promise.resolve(mockVoiceSetupReadiness.authoritativeReady), })); function deferred() { @@ -957,6 +964,8 @@ describe("AppShell global navigation", () => { document.documentElement.removeAttribute("data-global-composer-visible"); mockSessionWindowSupport.supported = false; mockVoiceSetupReadiness.ready = false; + mockVoiceSetupReadiness.authoritativeReady = false; + mockVoiceSetupReadiness.refreshPromise = null; mockVoiceSettingsEnabled.enabled = false; mockFocusSessionWindow.mockReset(); useSessionWindowStore.getState().setSnapshot([]); @@ -4225,7 +4234,7 @@ describe("AppShell global navigation", () => { ).toBeNull(); }); - it("preserves a ready voice start when returning from setup", async () => { + it("preserves a voice start when authoritative readiness leads the AppShell snapshot", async () => { const user = userEvent.setup(); const session = useChatSessionStore.getState().createDraftSession({ title: "Voice setup target", @@ -4252,7 +4261,7 @@ describe("AppShell global navigation", () => { expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); }); - mockVoiceSetupReadiness.ready = true; + mockVoiceSetupReadiness.authoritativeReady = true; view.rerender(appShellWithTheme()); await user.click(screen.getByRole("button", { name: "Back" })); @@ -4265,6 +4274,116 @@ describe("AppShell global navigation", () => { ); }); + it("lets a replacement Voice target return while the previous readiness refresh is pending", async () => { + const user = userEvent.setup(); + const first = useChatSessionStore.getState().createDraftSession({ + title: "First voice target", + workingDir: "/tmp/first-voice-target", + }); + const second = useChatSessionStore.getState().createDraftSession({ + title: "Second voice target", + workingDir: "/tmp/second-voice-target", + }); + const firstRefresh = deferred(); + const secondRefresh = deferred(); + mockVoiceSettingsEnabled.enabled = true; + renderAppShell(); + + const openVoiceSetup = (sessionId: string) => { + useVoiceConversationStore.getState().requestStart(sessionId); + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { type: "voice-setup", sessionId }, + }, + }), + ); + }; + + act(() => openVoiceSetup(first.id)); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + mockVoiceSetupReadiness.refreshPromise = firstRefresh.promise; + await user.click(screen.getByRole("button", { name: "Back" })); + + act(() => openVoiceSetup(second.id)); + mockVoiceSetupReadiness.refreshPromise = secondRefresh.promise; + await user.click(screen.getByRole("button", { name: "Back" })); + + firstRefresh.resolve(true); + await act(async () => { + await firstRefresh.promise; + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + + secondRefresh.resolve(true); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe(second.id); + }); + + it("guards Voice setup navigation from a dirty agent draft", async () => { + const user = userEvent.setup(); + renderAppShell(); + + await user.click(screen.getByRole("button", { name: "Sidebar agents" })); + await user.click(screen.getByRole("button", { name: "Create agent" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + await waitForCreatedAgentBuilderTarget(); + + const dirtyDraft = { + type: "agent" as const, + path: "/Users/test/.agents/agents/untitled-agent-created-session.md", + name: "Reviewer", + description: "Draft", + content: "Review code carefully.", + global: true, + writable: true, + properties: { draft: true, builderSessionId: "created-session" }, + }; + mockListPersonaSources.mockResolvedValue([dirtyDraft]); + mockReadAgentSourceFile.mockResolvedValue(dirtyDraft); + mockVoiceSettingsEnabled.enabled = true; + + const openVoiceSetup = () => { + useVoiceConversationStore.getState().requestStart("created-session"); + window.dispatchEvent( + new CustomEvent(OPEN_SETTINGS_EVENT, { + detail: { + section: "voice", + returnTarget: { + type: "voice-setup", + sessionId: "created-session", + }, + }, + }), + ); + }; + + act(openVoiceSetup); + await waitFor(() => { + expect(screen.getByText("Save this agent draft?")).toBeInTheDocument(); + }); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + + await user.click(screen.getByRole("button", { name: "Keep editing" })); + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + expect( + useVoiceConversationStore.getState().requestedStartSessionId, + ).toBeNull(); + + act(openVoiceSetup); + await user.click(await screen.findByRole("button", { name: "Discard" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + }); + it("discarding a dirty agent draft continues the pending navigation", async () => { const user = userEvent.setup(); renderAppShell(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 60a805de4..199081847 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -229,7 +229,10 @@ import { useVoiceConversationStore } from "@/features/voice-conversation/stores/ import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; import { useSiriVoiceSetup } from "@/features/voice-conversation/hooks/useSiriVoiceSetup"; import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voiceOutputPreference"; -import { isVoiceSetupReady } from "@/features/voice-conversation/lib/voiceSetupReadiness"; +import { + isVoiceSetupReady, + refreshVoiceSetupReadiness, +} from "@/features/voice-conversation/lib/voiceSetupReadiness"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; import { getOptimisticArtifactCwd } from "@/shared/artifacts/sessionArtifactLocation"; import { @@ -890,6 +893,7 @@ export function AppShell({ const [voiceSettingsReturnTarget, setVoiceSettingsReturnTarget] = useState(null); const voiceSettingsReturnTargetRef = useRef(voiceSettingsReturnTarget); + const voiceSettingsReturnInFlightRef = useRef(null); voiceSettingsReturnTargetRef.current = voiceSettingsReturnTarget; const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), @@ -3449,46 +3453,75 @@ export function AppShell({ return false; } + if (voiceSettingsReturnInFlightRef.current === target.sessionId) { + return true; + } + const session = useChatSessionStore.getState().getSession(target.sessionId); - voiceSettingsReturnTargetRef.current = null; - setVoiceSettingsReturnTarget(null); if (!session || session.archivedAt) { + voiceSettingsReturnTargetRef.current = null; + setVoiceSettingsReturnTarget(null); useVoiceConversationStore .getState() .clearRequestedStart(target.sessionId); return false; } - const history = navigationHistoryRef.current; - const previousLocation = - history.index > 0 ? history.entries[history.index - 1] : null; - if ( - previousLocation?.view === "chat" && - previousLocation.sessionId === target.sessionId - ) { - history.index -= 1; - } else { - history.entries.splice(history.index, 0, { - view: "chat", - sessionId: target.sessionId, - }); - } - if (!globalVoiceReady) { - useVoiceConversationStore - .getState() - .clearRequestedStart(target.sessionId); - } + voiceSettingsReturnInFlightRef.current = target.sessionId; + void refreshVoiceSetupReadiness( + globalVoiceOutput.backend, + globalSiriVoiceSetup.language, + ) + .then((ready) => { + if (!ready) { + useVoiceConversationStore + .getState() + .clearRequestedStart(target.sessionId); + } + }) + .catch(() => { + // Preserve the requested start when readiness cannot be confirmed. + // The session controller will consume it only after its live status is ready. + }) + .finally(() => { + if (voiceSettingsReturnInFlightRef.current === target.sessionId) { + voiceSettingsReturnInFlightRef.current = null; + } + if ( + voiceSettingsReturnTargetRef.current?.sessionId !== target.sessionId + ) { + return; + } + voiceSettingsReturnTargetRef.current = null; + setVoiceSettingsReturnTarget(null); - clearSettingsSectionUrl(); - setActiveSession(target.sessionId); - setActiveView("chat"); - setChatActiveSession(target.sessionId); - useChatStore.getState().markSessionRead(target.sessionId); - void loadSessionMessagesAndPrepare(target.sessionId); - updateNavigationAvailability(); + const history = navigationHistoryRef.current; + const previousLocation = + history.index > 0 ? history.entries[history.index - 1] : null; + if ( + previousLocation?.view === "chat" && + previousLocation.sessionId === target.sessionId + ) { + history.index -= 1; + } else { + history.entries.splice(history.index, 0, { + view: "chat", + sessionId: target.sessionId, + }); + } + + clearSettingsSectionUrl(); + setActiveSession(target.sessionId); + setActiveView("chat"); + setChatActiveSession(target.sessionId); + useChatStore.getState().markSessionRead(target.sessionId); + void loadSessionMessagesAndPrepare(target.sessionId); + updateNavigationAvailability(); + }); return true; }, [ - globalVoiceReady, + globalSiriVoiceSetup.language, + globalVoiceOutput.backend, setActiveSession, setChatActiveSession, updateNavigationAvailability, @@ -3581,27 +3614,39 @@ export function AppShell({ const handleOpenSettingsEvent = (event: Event) => { const detail = (event as CustomEvent).detail; const section = detail?.section; - setAgentBuilderSettingsReturnTarget( - detail?.returnTarget?.type === "agent-builder-provider-setup" - ? detail.returnTarget - : null, - ); - const currentVoiceTarget = voiceSettingsReturnTargetRef.current; const nextVoiceTarget = detail?.returnTarget?.type === "voice-setup" ? detail.returnTarget : null; - if ( - currentVoiceTarget && - currentVoiceTarget.sessionId !== nextVoiceTarget?.sessionId - ) { - useVoiceConversationStore - .getState() - .clearRequestedStart(currentVoiceTarget.sessionId); + const commitNavigation = () => { + setAgentBuilderSettingsReturnTarget( + detail?.returnTarget?.type === "agent-builder-provider-setup" + ? detail.returnTarget + : null, + ); + const currentVoiceTarget = voiceSettingsReturnTargetRef.current; + if ( + currentVoiceTarget && + currentVoiceTarget.sessionId !== nextVoiceTarget?.sessionId + ) { + useVoiceConversationStore + .getState() + .clearRequestedStart(currentVoiceTarget.sessionId); + } + voiceSettingsReturnTargetRef.current = nextVoiceTarget; + setVoiceSettingsReturnTarget(nextVoiceTarget); + openSettings(resolveSettingsSection(section ?? null)); + }; + + if (nextVoiceTarget) { + guardAppNavigation(commitNavigation, () => { + useVoiceConversationStore + .getState() + .clearRequestedStart(nextVoiceTarget.sessionId); + }); + return; } - voiceSettingsReturnTargetRef.current = nextVoiceTarget; - setVoiceSettingsReturnTarget(nextVoiceTarget); - openSettings(resolveSettingsSection(section ?? null)); + commitNavigation(); }; window.addEventListener( @@ -3614,7 +3659,7 @@ export function AppShell({ handleOpenSettingsEvent as EventListener, ); }; - }, [openSettings]); + }, [guardAppNavigation, openSettings]); const settleWorkspaceCleanupConfirmation = useCallback( (confirmed: boolean) => { diff --git a/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts b/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts index d90b72457..c83b1cbb1 100644 --- a/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts +++ b/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PocketVoiceStatus } from "../api/pocketVoice"; import type { SiriVoiceStatus } from "../api/siriVoice"; -import { isVoiceSetupReady } from "./voiceSetupReadiness"; +import { + isVoiceSetupReady, + refreshVoiceSetupReadiness, +} from "./voiceSetupReadiness"; + +const mockGetPocketVoiceStatus = vi.hoisted(() => vi.fn()); +const mockGetSiriVoiceStatus = vi.hoisted(() => vi.fn()); + +vi.mock("../api/pocketVoice", () => ({ + getPocketVoiceStatus: mockGetPocketVoiceStatus, +})); +vi.mock("../api/siriVoice", () => ({ + getSiriVoiceStatus: mockGetSiriVoiceStatus, +})); const pocket = { installed: true, @@ -16,6 +29,11 @@ const siri = { } as SiriVoiceStatus; describe("voice setup readiness", () => { + beforeEach(() => { + mockGetPocketVoiceStatus.mockReset(); + mockGetSiriVoiceStatus.mockReset(); + }); + it("requires Parakeet and Pocket for the Pocket backend", () => { expect(isVoiceSetupReady(pocket, null, "pocket")).toBe(true); expect( @@ -37,4 +55,25 @@ describe("voice setup readiness", () => { ), ).toBe(false); }); + + it("refreshes Pocket readiness without querying Siri", async () => { + mockGetPocketVoiceStatus.mockResolvedValue(pocket); + + await expect(refreshVoiceSetupReadiness("pocket", "en-US")).resolves.toBe( + true, + ); + expect(mockGetPocketVoiceStatus).toHaveBeenCalledOnce(); + expect(mockGetSiriVoiceStatus).not.toHaveBeenCalled(); + }); + + it("refreshes Siri readiness for the selected language", async () => { + mockGetPocketVoiceStatus.mockResolvedValue(pocket); + mockGetSiriVoiceStatus.mockResolvedValue(siri); + + await expect(refreshVoiceSetupReadiness("siri", "en-AU")).resolves.toBe( + true, + ); + expect(mockGetPocketVoiceStatus).toHaveBeenCalledOnce(); + expect(mockGetSiriVoiceStatus).toHaveBeenCalledWith("en-AU"); + }); }); diff --git a/src/features/voice-conversation/lib/voiceSetupReadiness.ts b/src/features/voice-conversation/lib/voiceSetupReadiness.ts index e8b868c1c..0681b7b36 100644 --- a/src/features/voice-conversation/lib/voiceSetupReadiness.ts +++ b/src/features/voice-conversation/lib/voiceSetupReadiness.ts @@ -1,5 +1,8 @@ -import type { PocketVoiceStatus } from "../api/pocketVoice"; -import type { SiriVoiceStatus } from "../api/siriVoice"; +import { + getPocketVoiceStatus, + type PocketVoiceStatus, +} from "../api/pocketVoice"; +import { getSiriVoiceStatus, type SiriVoiceStatus } from "../api/siriVoice"; import type { VoiceOutputBackend } from "./voiceOutputPreference"; export function isVoiceSetupReady( @@ -13,3 +16,14 @@ export function isVoiceSetupReady( siri?.supported && siri.selectedVoice && siri.selectedVoiceInstalled, ); } + +export async function refreshVoiceSetupReadiness( + backend: VoiceOutputBackend, + siriLanguage: string, +): Promise { + const [pocket, siri] = await Promise.all([ + getPocketVoiceStatus(), + backend === "siri" ? getSiriVoiceStatus(siriLanguage) : null, + ]); + return isVoiceSetupReady(pocket, siri, backend); +} diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx index 5d17c1fda..aaee8206d 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.test.tsx @@ -2,6 +2,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/test/render"; +import { i18n } from "@/shared/i18n"; import type { SiriVoiceSetup } from "../hooks/useSiriVoiceSetup"; import { SiriVoiceSettings } from "./SiriVoiceSettings"; @@ -83,6 +84,67 @@ describe("SiriVoiceSettings", () => { ).toBeInTheDocument(); }); + it("sorts language options and groups with the active Berd locale", async () => { + const nativeCollator = Intl.Collator; + await i18n.changeLanguage("es"); + try { + const voices = [ + { + name: "Voz española", + language: "es-ES", + sizeBytes: 1, + installed: true, + }, + { + name: "Voz francesa", + language: "fr-FR", + sizeBytes: 1, + installed: true, + }, + { + name: "Voz inglesa", + language: "en-US", + sizeBytes: 1, + installed: true, + }, + ]; + const status = setup().status; + expect(status).not.toBeNull(); + if (!status) return; + const value = setup({ + languages: ["fr-FR", "en-US", "es-ES"], + status: { + ...status, + availableLanguages: ["fr-FR", "en-US", "es-ES"], + voices, + }, + }); + + renderWithProviders(); + + const displayNames = new Intl.DisplayNames(["es"], { + type: "language", + languageDisplay: "standard", + }); + const collator = new nativeCollator("es"); + const expected = ["fr-FR", "en-US", "es-ES"] + .map((locale) => displayNames.of(locale) ?? locale) + .sort(collator.compare); + expect( + screen + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent), + ).toEqual(expected); + + await userEvent.click(screen.getByRole("combobox", { name: "Idioma" })); + expect( + screen.getAllByRole("option").map((option) => option.textContent), + ).toEqual(expected); + } finally { + await i18n.changeLanguage("en"); + } + }); + it("previews a Siri voice before download", async () => { const value = setup(); renderWithProviders(); diff --git a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx index e22ba7220..93f1f14b3 100644 --- a/src/features/voice-conversation/ui/SiriVoiceSettings.tsx +++ b/src/features/voice-conversation/ui/SiriVoiceSettings.tsx @@ -28,7 +28,11 @@ function localeLabel(locale: string, displayLocale?: string): string { } } -function groupVoicesByLocale(voices: SiriVoice[], displayLocale?: string) { +function groupVoicesByLocale( + voices: SiriVoice[], + displayLocale: string, + collator: Intl.Collator, +) { const groups = new Map(); for (const voice of voices) { groups.set(voice.language, [...(groups.get(voice.language) ?? []), voice]); @@ -36,10 +40,11 @@ function groupVoicesByLocale(voices: SiriVoice[], displayLocale?: string) { return Array.from(groups, ([locale, groupedVoices]) => ({ locale, voices: groupedVoices.sort((left, right) => - left.name.localeCompare(right.name), + collator.compare(left.name, right.name), ), })).sort((left, right) => - localeLabel(left.locale, displayLocale).localeCompare( + collator.compare( + localeLabel(left.locale, displayLocale), localeLabel(right.locale, displayLocale), ), ); @@ -52,18 +57,24 @@ function formatBytes(bytes: number): string { export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { const { t, i18n } = useTranslation("settings"); const displayLocale = i18n.resolvedLanguage ?? i18n.language; + const collator = useMemo( + () => new Intl.Collator(displayLocale), + [displayLocale], + ); const languages = useMemo( () => [...setup.languages].sort((left, right) => - localeLabel(left, displayLocale).localeCompare( + collator.compare( + localeLabel(left, displayLocale), localeLabel(right, displayLocale), ), ), - [displayLocale, setup.languages], + [collator, displayLocale, setup.languages], ); const groups = useMemo( - () => groupVoicesByLocale(setup.status?.voices ?? [], displayLocale), - [displayLocale, setup.status?.voices], + () => + groupVoicesByLocale(setup.status?.voices ?? [], displayLocale, collator), + [collator, displayLocale, setup.status?.voices], ); const selectedKey = setup.status?.selectedVoice ? voiceKey(setup.status.selectedVoice) diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index 5da70ca6a..683f1582f 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.test.tsx @@ -154,6 +154,14 @@ describe("VoiceSettings", () => { ).toHaveAccessibleDescription( "Choose how Berd speaks assistant responses.", ); + const outputPicker = screen.getByRole("combobox", { + name: "Speech output", + }); + expect(outputPicker).toHaveClass("w-full", "sm:w-auto"); + expect( + screen.getByRole("heading", { name: "Speech output" }).parentElement + ?.parentElement, + ).toHaveClass("flex-col", "sm:flex-row"); }); it("keeps the Voice settings page open while Parakeet completes in place", () => { diff --git a/src/features/voice-conversation/ui/VoiceSettings.tsx b/src/features/voice-conversation/ui/VoiceSettings.tsx index 7b0da897f..de63121eb 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.tsx @@ -84,8 +84,8 @@ export function VoiceSettings() { showPocketVoiceControls={false} />
-
-
+
+

{t("voice.speechOutput")} @@ -97,7 +97,7 @@ export function VoiceSettings() { {t("voice.outputBackendDescription")}

-
+