diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 095c4cb48..2567443a5 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"; @@ -105,6 +106,36 @@ const mockAfterNextPaint = vi.hoisted(() => ({ })); const mockSessionWindowSupport = vi.hoisted(() => ({ supported: false })); const mockFocusSessionWindow = vi.hoisted(() => vi.fn()); +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) => { + 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, + refreshStableVoiceSetupReadiness: () => + mockVoiceSetupReadiness.refreshPromise ?? + Promise.resolve(mockVoiceSetupReadiness.authoritativeReady), +})); function deferred() { let resolve!: (value: T) => void; @@ -932,6 +963,10 @@ describe("AppShell global navigation", () => { useShortcutsDialogStore.setState({ open: false }); 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([]); mockListExtensions.mockReset(); @@ -1054,6 +1089,7 @@ describe("AppShell global navigation", () => { activeWorkspaceBySession: {}, archiveMutationBySessionId: {}, }); + useVoiceConversationStore.setState({ requestedStartSessionId: null }); useAgentStore.setState({ selectedProvider: "goose", }); @@ -4080,6 +4116,273 @@ 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); + 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: "Back" })); + + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("chat"); + }); + expect(useChatSessionStore.getState().activeSessionId).toBe(session.id); + 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); + 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 skills" })); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("skills"); + }); + expect( + useVoiceConversationStore.getState().requestedStartSessionId, + ).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 voice start when authoritative readiness leads the AppShell snapshot", 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.authoritativeReady = 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("lets a reopened Voice target return while its previous readiness refresh is pending", async () => { + const user = userEvent.setup(); + const session = useChatSessionStore.getState().createDraftSession({ + title: "Voice target", + workingDir: "/tmp/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(session.id)); + await waitFor(() => { + expect(screen.getByTestId("active-view")).toHaveTextContent("settings"); + }); + mockVoiceSetupReadiness.refreshPromise = firstRefresh.promise; + await user.click(screen.getByRole("button", { name: "Back" })); + + act(() => openVoiceSetup(session.id)); + mockVoiceSetupReadiness.refreshPromise = secondRefresh.promise; + await user.click(screen.getByRole("button", { name: "Back" })); + + firstRefresh.resolve(false); + 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(session.id); + expect(useVoiceConversationStore.getState().requestedStartSessionId).toBe( + session.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 c08504812..b12944838 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -31,8 +31,10 @@ import { } from "@/features/settings/ui/settingsSections"; import { OPEN_SETTINGS_EVENT, + 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"; @@ -226,15 +228,11 @@ 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"; + isVoiceSetupReady, + refreshStableVoiceSetupReadiness, +} from "@/features/voice-conversation/lib/voiceSetupReadiness"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; import { getOptimisticArtifactCwd } from "@/shared/artifacts/sessionArtifactLocation"; import { @@ -721,15 +719,28 @@ export function AppShell({ const globalSiriVoiceSetup = useSiriVoiceSetup( capabilities.voiceConversation && globalVoiceOutput.backend === "siri", ); + const globalVoiceSetupSelectionRef = useRef({ + backend: globalVoiceOutput.backend, + siriLanguage: globalSiriVoiceSetup.language, + revision: 0, + }); + if ( + globalVoiceSetupSelectionRef.current.backend !== + globalVoiceOutput.backend || + globalVoiceSetupSelectionRef.current.siriLanguage !== + globalSiriVoiceSetup.language + ) { + globalVoiceSetupSelectionRef.current = { + backend: globalVoiceOutput.backend, + siriLanguage: globalSiriVoiceSetup.language, + revision: globalVoiceSetupSelectionRef.current.revision + 1, + }; + } const globalVoiceReady = isVoiceSetupReady( globalPocketVoiceSetup.status, 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 +756,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); @@ -898,6 +907,12 @@ export function AppShell({ agentBuilderSettingsReturnTarget, setAgentBuilderSettingsReturnTarget, ] = useState(null); + const [voiceSettingsReturnTarget, setVoiceSettingsReturnTarget] = + useState(null); + const voiceSettingsReturnTargetRef = useRef(voiceSettingsReturnTarget); + const voiceSettingsReturnInFlightRef = useRef(null); + const voiceSettingsReturnGenerationRef = useRef(0); + voiceSettingsReturnTargetRef.current = voiceSettingsReturnTarget; const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), ); @@ -3213,20 +3228,8 @@ 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; - } - const options = payload.options; const project = options?.projectId ? projects.find((candidate) => candidate.id === options.projectId) @@ -3281,8 +3284,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; }; @@ -3319,22 +3329,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(() => { @@ -3471,6 +3465,105 @@ export function AppShell({ setChatActiveSession, ]); + const returnToVoiceSettingsTarget = useCallback(() => { + const target = voiceSettingsReturnTarget; + if (!target) { + return false; + } + + if (voiceSettingsReturnInFlightRef.current === target.sessionId) { + return true; + } + + const session = useChatSessionStore.getState().getSession(target.sessionId); + if (!session || session.archivedAt) { + voiceSettingsReturnGenerationRef.current += 1; + voiceSettingsReturnInFlightRef.current = null; + voiceSettingsReturnTargetRef.current = null; + setVoiceSettingsReturnTarget(null); + useVoiceConversationStore + .getState() + .clearRequestedStart(target.sessionId); + return false; + } + + voiceSettingsReturnInFlightRef.current = target.sessionId; + const returnGeneration = ++voiceSettingsReturnGenerationRef.current; + void refreshStableVoiceSetupReadiness( + () => globalVoiceSetupSelectionRef.current, + ) + .then((ready) => { + if ( + !ready && + voiceSettingsReturnGenerationRef.current === returnGeneration + ) { + 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 ( + voiceSettingsReturnGenerationRef.current !== returnGeneration || + voiceSettingsReturnTargetRef.current?.sessionId !== target.sessionId + ) { + return; + } + voiceSettingsReturnInFlightRef.current = null; + voiceSettingsReturnTargetRef.current = null; + setVoiceSettingsReturnTarget(null); + + 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; + }, [ + setActiveSession, + setChatActiveSession, + updateNavigationAvailability, + voiceSettingsReturnTarget, + ]); + + useEffect(() => { + if ( + !voiceSettingsReturnTarget || + (activeView === "settings" && activeSettingsSection === "voice") + ) { + return; + } + useVoiceConversationStore + .getState() + .clearRequestedStart(voiceSettingsReturnTarget.sessionId); + voiceSettingsReturnGenerationRef.current += 1; + voiceSettingsReturnInFlightRef.current = null; + voiceSettingsReturnTargetRef.current = null; + setVoiceSettingsReturnTarget(null); + }, [activeSettingsSection, activeView, voiceSettingsReturnTarget]); + const openSettings = useCallback( (section: SectionId = DEFAULT_SETTINGS_SECTION) => { const enabledSection = resolveEnabledSettingsSection( @@ -3491,12 +3584,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) => { @@ -3540,12 +3636,43 @@ 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" + const nextVoiceTarget = + detail?.returnTarget?.type === "voice-setup" ? detail.returnTarget - : null, - ); - openSettings(resolveSettingsSection(section ?? null)); + : null; + 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); + } + if (voiceSettingsReturnInFlightRef.current !== null) { + voiceSettingsReturnGenerationRef.current += 1; + voiceSettingsReturnInFlightRef.current = null; + } + voiceSettingsReturnTargetRef.current = nextVoiceTarget; + setVoiceSettingsReturnTarget(nextVoiceTarget); + openSettings(resolveSettingsSection(section ?? null)); + }; + + if (nextVoiceTarget) { + guardAppNavigation(commitNavigation, () => { + useVoiceConversationStore + .getState() + .clearRequestedStart(nextVoiceTarget.sessionId); + }); + return; + } + commitNavigation(); }; window.addEventListener( @@ -3558,7 +3685,7 @@ export function AppShell({ handleOpenSettingsEvent as EventListener, ); }; - }, [openSettings]); + }, [guardAppNavigation, openSettings]); const settleWorkspaceCleanupConfirmation = useCallback( (confirmed: boolean) => { @@ -4228,6 +4355,10 @@ export function AppShell({ ); const goBack = useCallback(() => { + if (activeView === "settings" && returnToVoiceSettingsTarget()) { + updateNavigationAvailability(); + return; + } if (activeView === "settings" && agentBuilderSettingsReturnTarget) { const history = navigationHistoryRef.current; const previousLocation = @@ -4262,6 +4393,7 @@ export function AppShell({ applyNavigationLocation, guardAppNavigation, returnToAgentBuilderSettingsTarget, + returnToVoiceSettingsTarget, updateNavigationAvailability, ]); @@ -5325,15 +5457,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 +256,10 @@ export function ChatView({ isGooseSession: controller.selectedProvider === "goose", pocketReady: voiceReady, onPocketSetupRequired: () => { - pendingPocketVoiceStartRef.current = sessionId; - setPocketVoiceSetupOpen(true); + requestVoiceConversationStart(sessionId); + requestOpenSettings("voice", { + returnTarget: { type: "voice-setup", sessionId }, + }); }, readOnly: Boolean(readOnlyStatus), disabled: @@ -270,16 +269,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 +988,6 @@ export function ChatView({ sessionCwd={controller.sessionArtifactCwd} sessionId={sessionId} > - vi.fn()); + +vi.mock("../api/siriVoice", async (importOriginal) => ({ + ...(await importOriginal()), + getSiriVoiceStatus: mockGetSiriVoiceStatus, +})); + +const originalTauriInternals = window.__TAURI_INTERNALS__; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} + +function status(language: string, name: string): SiriVoiceStatus { + return { + supported: true, + availableLanguages: [language], + selectedVoice: { name, language }, + selectedVoiceInstalled: true, + playbackSpeed: 1, + voices: [{ name, language, sizeBytes: 1, installed: true }], + }; +} + +beforeEach(() => { + mockGetSiriVoiceStatus.mockReset(); + window.__TAURI_INTERNALS__ = {} as typeof window.__TAURI_INTERNALS__; +}); + +afterEach(() => { + window.__TAURI_INTERNALS__ = originalTauriInternals; +}); + describe("Siri voice locales", () => { it("preserves exact regional variants", () => { expect(availableLocales(["en_US", "en-AU", "en-IN", "en-US"])).toEqual([ @@ -34,4 +76,63 @@ describe("Siri voice locales", () => { }), ).toBe("en-AU"); }); + + it("ignores an old-language failure after the current language succeeds", async () => { + const oldRequest = deferred(); + const currentRequest = deferred(); + mockGetSiriVoiceStatus.mockImplementation((language: string) => + language === "en-AU" ? currentRequest.promise : oldRequest.promise, + ); + const { result } = renderHook(() => useSiriVoiceSetup(true)); + + await waitFor(() => expect(mockGetSiriVoiceStatus).toHaveBeenCalled()); + act(() => result.current.setLanguage("en-AU")); + await waitFor(() => + expect(mockGetSiriVoiceStatus).toHaveBeenCalledWith("en-AU", { + coalesce: true, + }), + ); + + currentRequest.resolve(status("en-AU", "Catherine")); + await waitFor(() => + expect(result.current.status?.selectedVoice?.name).toBe("Catherine"), + ); + + oldRequest.reject(new Error("Old catalog failed")); + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.status?.selectedVoice?.name).toBe("Catherine"); + expect(result.current.error).toBeNull(); + expect(result.current.statusError).toBeNull(); + }); + + it("ignores a refresh failure after Siri setup is disabled", async () => { + const focusRefresh = deferred(); + mockGetSiriVoiceStatus + .mockResolvedValueOnce(status("en-US", "Aaron")) + .mockReturnValueOnce(focusRefresh.promise); + const { result, rerender } = renderHook( + ({ enabled }) => useSiriVoiceSetup(enabled), + { initialProps: { enabled: true } }, + ); + await waitFor(() => + expect(result.current.status?.selectedVoice?.name).toBe("Aaron"), + ); + + act(() => window.dispatchEvent(new Event("focus"))); + await waitFor(() => + expect(mockGetSiriVoiceStatus).toHaveBeenCalledTimes(2), + ); + rerender({ enabled: false }); + await waitFor(() => expect(result.current.status).toBeNull()); + + focusRefresh.reject(new Error("Stale focus refresh failed")); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.error).toBeNull(); + expect(result.current.statusError).toBeNull(); + }); }); diff --git a/src/features/voice-conversation/hooks/useSiriVoiceSetup.ts b/src/features/voice-conversation/hooks/useSiriVoiceSetup.ts index 4d4d8d67b..8db42e988 100644 --- a/src/features/voice-conversation/hooks/useSiriVoiceSetup.ts +++ b/src/features/voice-conversation/hooks/useSiriVoiceSetup.ts @@ -64,6 +64,7 @@ export interface SiriVoiceSetup { languages: string[]; loading: boolean; error: string | null; + statusError: string | null; downloadingVoiceKey: string | null; previewingVoiceKey: string | null; setLanguage: (language: string) => void; @@ -82,6 +83,7 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { ); const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); + const [statusError, setStatusError] = useState(null); const [downloadingVoiceKey, setDownloadingVoiceKey] = useState( null, ); @@ -89,6 +91,7 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { null, ); const languageRef = useRef(language); + const statusRequestGenerationRef = useRef(0); const initialSelectedLocaleAppliedRef = useRef(false); const languageSelectedByUserRef = useRef(false); languageRef.current = language; @@ -99,12 +102,28 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { }, []); const refresh = useCallback(async (prefix: string) => { - const next = await getSiriVoiceStatus(prefix, { coalesce: true }); - if (canonicalLocale(languageRef.current) === canonicalLocale(prefix)) { - setStatus(next); - setError(null); + const generation = ++statusRequestGenerationRef.current; + try { + const next = await getSiriVoiceStatus(prefix, { coalesce: true }); + if ( + statusRequestGenerationRef.current === generation && + canonicalLocale(languageRef.current) === canonicalLocale(prefix) + ) { + setStatus(next); + setError(null); + setStatusError(null); + } + return next; + } catch (nextError) { + if ( + statusRequestGenerationRef.current === generation && + canonicalLocale(languageRef.current) === canonicalLocale(prefix) + ) { + setError(String(nextError)); + setStatusError(String(nextError)); + } + return null; } - return next; }, []); useEffect(() => { @@ -114,29 +133,35 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { return; } let active = true; + const generation = ++statusRequestGenerationRef.current; setLoading(true); setError(null); + setStatusError(null); void getSiriVoiceStatus(language, { coalesce: true }) .then((next) => { - if (active) setStatus(next); + if (active && statusRequestGenerationRef.current === generation) { + setStatus(next); + } }) .catch((nextError) => { - if (active) setError(String(nextError)); + if (active && statusRequestGenerationRef.current === generation) { + setError(String(nextError)); + setStatusError(String(nextError)); + } }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; + statusRequestGenerationRef.current += 1; }; }, [enabled, language]); useEffect(() => { if (!enabled || !window.__TAURI_INTERNALS__) return; const handleSettingsChanged = () => { - void refresh(language).catch((nextError) => { - setError(String(nextError)); - }); + void refresh(language); }; window.addEventListener(SIRI_VOICE_SETTINGS_CHANGED, handleSettingsChanged); return () => { @@ -179,9 +204,7 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { useEffect(() => { if (!enabled || !window.__TAURI_INTERNALS__) return; const handleFocus = () => { - void refresh(language).catch((nextError) => { - setError(String(nextError)); - }); + void refresh(language); }; window.addEventListener("focus", handleFocus); return () => window.removeEventListener("focus", handleFocus); @@ -252,6 +275,7 @@ export function useSiriVoiceSetup(enabled = true): SiriVoiceSetup { languages, loading, error, + statusError, downloadingVoiceKey, previewingVoiceKey, setLanguage: selectLanguage, diff --git a/src/features/voice-conversation/lib/pendingVoiceStart.test.ts b/src/features/voice-conversation/lib/pendingVoiceStart.test.ts deleted file mode 100644 index 624cb0923..000000000 --- a/src/features/voice-conversation/lib/pendingVoiceStart.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - cancelPendingVoiceStart, - consumePendingVoiceStart, - continuePendingVoiceStart, - deferPendingVoiceStart, -} from "./pendingVoiceStart"; - -describe("consumePendingVoiceStart", () => { - 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/lib/voiceSetupReadiness.test.ts b/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts index d90b72457..36ce90ad7 100644 --- a/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts +++ b/src/features/voice-conversation/lib/voiceSetupReadiness.test.ts @@ -1,7 +1,21 @@ -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, + refreshStableVoiceSetupReadiness, + 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 +30,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 +56,71 @@ 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"); + }); + + it("rechecks readiness when the selected backend changes during refresh", async () => { + let resolvePocket!: (status: PocketVoiceStatus) => void; + const firstPocket = new Promise((resolve) => { + resolvePocket = resolve; + }); + mockGetPocketVoiceStatus + .mockReturnValueOnce(firstPocket) + .mockResolvedValueOnce(pocket); + mockGetSiriVoiceStatus.mockResolvedValue(siri); + let selection: { + backend: "pocket" | "siri"; + siriLanguage: string; + revision: number; + } = { backend: "pocket", siriLanguage: "en-US", revision: 0 }; + + const readiness = refreshStableVoiceSetupReadiness(() => selection); + selection = { backend: "siri", siriLanguage: "en-AU", revision: 1 }; + resolvePocket({ ...pocket, pocketInstalled: false }); + + await expect(readiness).resolves.toBe(true); + expect(mockGetPocketVoiceStatus).toHaveBeenCalledTimes(2); + expect(mockGetSiriVoiceStatus).toHaveBeenCalledWith("en-AU"); + }); + + it("rechecks readiness after an A-to-B-to-A selection change", async () => { + let resolveFirst!: (status: PocketVoiceStatus) => void; + const firstPocket = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockGetPocketVoiceStatus + .mockReturnValueOnce(firstPocket) + .mockResolvedValueOnce(pocket); + let selection = { + backend: "pocket" as const, + siriLanguage: "en-US", + revision: 0, + }; + + const readiness = refreshStableVoiceSetupReadiness(() => selection); + selection = { ...selection, revision: 2 }; + resolveFirst({ ...pocket, pocketInstalled: false }); + + await expect(readiness).resolves.toBe(true); + expect(mockGetPocketVoiceStatus).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/features/voice-conversation/lib/voiceSetupReadiness.ts b/src/features/voice-conversation/lib/voiceSetupReadiness.ts index e8b868c1c..a09332e72 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,40 @@ 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); +} + +export interface VoiceSetupSelection { + backend: VoiceOutputBackend; + siriLanguage: string; + revision: number; +} + +export async function refreshStableVoiceSetupReadiness( + getSelection: () => VoiceSetupSelection, +): Promise { + for (;;) { + const selection = getSelection(); + const ready = await refreshVoiceSetupReadiness( + selection.backend, + selection.siriLanguage, + ); + const current = getSelection(); + if ( + current.backend === selection.backend && + current.siriLanguage === selection.siriLanguage && + current.revision === selection.revision + ) { + return ready; + } + } +} 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", models: visibleModels, showPocketVoiceControls = true, }: { setup: PocketVoiceSetup; - presentation?: "dialog" | "settings"; models?: VoiceModelKind[]; showPocketVoiceControls?: boolean; }) { @@ -162,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, @@ -195,16 +67,8 @@ export function PocketVoiceSetupContent({ ].filter(({ model }) => !visibleModels || visibleModels.includes(model)); return ( -
-
+
+
{models.map( ({ model, @@ -219,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 eb30be08f..338b29221 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"; @@ -17,7 +18,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,9 +32,10 @@ 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, + statusError: null, downloadingVoiceKey: null, previewingVoiceKey: null, setLanguage: vi.fn(), @@ -52,13 +54,114 @@ 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("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, + }, + { + name: "Nza", + language: "en-US", + sizeBytes: 1, + installed: true, + }, + { + name: "Ña", + 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); + expect( + screen + .getByText("Nza") + .compareDocumentPosition(screen.getByText("Ña")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + 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 () => { @@ -76,7 +179,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; @@ -110,6 +213,15 @@ describe("SiriVoiceSettings", () => { expect( screen.getByRole("button", { name: "Download Quinn" }), ).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(); + + 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 bfd9ba48d..93f1f14b3 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"; @@ -15,29 +15,24 @@ 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, + collator: Intl.Collator, +) { const groups = new Map(); for (const voice of voices) { groups.set(voice.language, [...(groups.get(voice.language) ?? []), voice]); @@ -45,10 +40,13 @@ function groupVoicesByLocale(voices: SiriVoice[]) { 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).localeCompare(localeLabel(right.locale)), + collator.compare( + localeLabel(left.locale, displayLocale), + localeLabel(right.locale, displayLocale), + ), ); } @@ -57,17 +55,26 @@ 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 collator = useMemo( + () => new Intl.Collator(displayLocale), + [displayLocale], + ); const languages = useMemo( () => [...setup.languages].sort((left, right) => - languageLabel(left).localeCompare(languageLabel(right)), + collator.compare( + localeLabel(left, displayLocale), + localeLabel(right, displayLocale), + ), ), - [setup.languages], + [collator, displayLocale, setup.languages], ); const groups = useMemo( - () => groupVoicesByLocale(setup.status?.voices ?? []), - [setup.status?.voices], + () => + groupVoicesByLocale(setup.status?.voices ?? [], displayLocale, collator), + [collator, displayLocale, setup.status?.voices], ); const selectedKey = setup.status?.selectedVoice ? voiceKey(setup.status.selectedVoice) @@ -94,7 +101,7 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { {languages.map((language) => ( - {languageLabel(language)} + {localeLabel(language, displayLocale)} ))} @@ -147,7 +154,7 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) { {groups.map((group) => (

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

{group.voices.map((voice) => { @@ -155,72 +162,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.modelInstalledSize", { + 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 ? ( +
); })} @@ -247,5 +264,3 @@ export function SiriVoiceSettings({ setup }: { setup: SiriVoiceSetup }) {
); } - -export { groupVoicesByLocale, languageLabel, localeLabel }; diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index 3670750f7..d07a8fb82 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,124 @@ 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: { + 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, + statusError: 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, + 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.", + ); + 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", () => { const missing: PocketVoiceStatus = { statusRevision: 4, @@ -100,4 +236,131 @@ 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.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", + statusError: "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("keeps readiness guidance visible for a Siri action error", () => { + outputState.backend = "siri"; + const current = siriSetup(); + siriSetupState.current = { + ...current, + status: current.status + ? { + ...current.status, + selectedVoice: null, + selectedVoiceInstalled: false, + } + : null, + error: "Preview failed", + statusError: null, + }; + + renderWithProviders(); + + expect(screen.getByText("Preview failed")).toBeInTheDocument(); + expect( + screen.getByText(/No installed Siri voice is selected/), + ).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", + statusError: "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 8b87025d8..8549c7f50 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 { SettingsRow } from "@/shared/ui/settings-row"; +import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Select, SelectContent, @@ -13,15 +15,53 @@ 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"; +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(); const output = useVoiceOutputPreference(); const siriSetup = useSiriVoiceSetup(output.backend === "siri"); 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 siriOutputLoaded = + siriSetup.status !== null && siriSetup.statusError === 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 ( + {readinessKey ? ( + + + {t("voice.notReadyTitle")} + {t(readinessKey)} + + ) : null}

{t("voice.speechInput")}

-
-

{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..31a060b0e 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,12 @@ "modelInstalledSize": "Installed · {{size}} on disk", "modelMissingSize": "Not installed · {{size}} download", "modelNotInstalled": "Not installed", - "notNow": "Not now", - "outputBackend": "Speech engine", + "notReadyInput": "Parakeet STT is not installed. Download it below to use Voice Conversation.", + "notReadyInputAndPocketOutput": "Parakeet STT and Pocket TTS are not installed. Download both below to use Voice Conversation.", + "notReadyInputAndSiriOutput": "Parakeet STT is not installed, and no installed Siri voice is selected. Complete both steps below to use Voice Conversation.", + "notReadyPocketOutput": "Pocket TTS is not installed. Download it below to use Voice Conversation.", + "notReadySiriOutput": "No installed Siri voice is selected. Download or select one below to use Voice Conversation.", + "notReadyTitle": "Voice Conversation isn't ready", "outputBackendDescription": "Choose how Berd speaks assistant responses.", "playbackSpeed": "Playback speed", "playing": "Playing", @@ -899,21 +902,14 @@ "removingModel": "Removing model…", "retryDownload": "Retry model download", "settingsDescription": "Choose how Berd speaks, install speech recognition, and preview available voices.", - "siriDownloadSize": "Available · {{size}} download", - "siriDownloading": "Downloading…", - "siriInstalled": "Installed", "siriLanguage": "Language", "siriLanguageDescription": "Choose the exact language and regional voice you want to use.", "siriLoading": "Loading Siri voices…", "siriNoVoices": "No Siri voices are available for this language.", - "siriSelected": "Selected", "siriUnsupported": "Siri voices are available on macOS only.", - "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..dc4a7029f 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,12 @@ "modelInstalledSize": "Instalado · {{size}} en disco", "modelMissingSize": "No instalado · descarga de {{size}}", "modelNotInstalled": "No instalado", - "notNow": "Ahora no", - "outputBackend": "Motor de voz", + "notReadyInput": "Parakeet STT no está instalado. Descárgalo abajo para usar la conversación por voz.", + "notReadyInputAndPocketOutput": "Parakeet STT y Pocket TTS no están instalados. Descarga ambos abajo para usar la conversación por voz.", + "notReadyInputAndSiriOutput": "Parakeet STT no está instalado y no hay ninguna voz de Siri instalada seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", + "notReadyPocketOutput": "Pocket TTS no está instalado. Descárgalo abajo para usar la conversación por voz.", + "notReadySiriOutput": "No hay ninguna voz de Siri instalada seleccionada. Descarga o selecciona una abajo para usar la conversación por voz.", + "notReadyTitle": "La conversación por voz no está lista", "outputBackendDescription": "Elige cómo Berd reproduce las respuestas del asistente.", "playbackSpeed": "Velocidad de reproducción", "playing": "Reproduciendo", @@ -902,21 +905,14 @@ "removingModel": "Eliminando modelo…", "retryDownload": "Reintentar descarga del modelo", "settingsDescription": "Elige cómo habla Berd, instala el reconocimiento de voz y escucha las voces disponibles.", - "siriDownloadSize": "Disponible · descarga de {{size}}", - "siriDownloading": "Descargando…", - "siriInstalled": "Instalada", "siriLanguage": "Idioma", "siriLanguageDescription": "Elige el idioma exacto y la voz regional que quieres usar.", "siriLoading": "Cargando voces de Siri…", "siriNoVoices": "No hay voces de Siri disponibles para este idioma.", - "siriSelected": "Seleccionada", "siriUnsupported": "Las voces de Siri solo están disponibles en macOS.", - "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" }