From 93095aa1d2aa128bfeea60e679de58d27f3368fa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Apr 2026 17:23:17 +0700 Subject: [PATCH] Fix dictation readiness after app refresh --- packages/app/src/components/composer.test.tsx | 65 ++++++++++++++++- packages/app/src/components/composer.tsx | 11 +-- .../app/src/components/message-input.test.tsx | 73 +++++++++++++++++-- packages/app/src/components/message-input.tsx | 6 +- .../app/src/hooks/use-is-dictation-ready.ts | 44 +++++++++++ 5 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/hooks/use-is-dictation-ready.ts diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx index e30dc1c09..b09732b55 100644 --- a/packages/app/src/components/composer.test.tsx +++ b/packages/app/src/components/composer.test.tsx @@ -25,6 +25,7 @@ const { setAgentStreamTailMock, setAgentStreamHeadMock, setQueuedMessagesMock, + agentDirectoryStatusMock, } = vi.hoisted(() => { const theme = { spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, @@ -103,6 +104,17 @@ const { string, { agents: Map; + serverInfo: { + serverId: string; + hostname: string | null; + version: string | null; + capabilities?: { + voice?: { + dictation: { enabled: boolean; reason: string }; + voice: { enabled: boolean; reason: string }; + }; + }; + } | null; queuedMessages: Map; agentStreamHead: Map; agentStreamTail: Map; @@ -115,6 +127,17 @@ const { sessions: { server: { agents: new Map([["agent", { status: "idle", lastUsage: null }]]), + serverInfo: { + serverId: "server", + hostname: "test", + version: "0.0.0", + capabilities: { + voice: { + dictation: { enabled: true, reason: "" }, + voice: { enabled: true, reason: "" }, + }, + }, + }, queuedMessages: new Map(), agentStreamHead: new Map(), agentStreamTail: new Map(), @@ -137,6 +160,7 @@ const { mockSessionState.setAgentStreamTail = setAgentStreamTailMock; mockSessionState.setAgentStreamHead = setAgentStreamHeadMock; const markScrollInvestigationRenderMock = vi.fn(); + const agentDirectoryStatusMock = vi.fn(() => "ready"); return { theme, @@ -154,6 +178,7 @@ const { setAgentStreamTailMock, setAgentStreamHeadMock, setQueuedMessagesMock, + agentDirectoryStatusMock, }; }); @@ -220,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({ vi.mock("@/runtime/host-runtime", () => ({ useHostRuntimeClient: () => mockClient, useHostRuntimeIsConnected: () => true, - useHostRuntimeAgentDirectoryStatus: () => "ready", + useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(), })); vi.mock("@/stores/session-store", () => { @@ -363,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({ })); vi.mock("@/utils/server-info-capabilities", () => ({ + getVoiceReadinessState: ({ + serverInfo, + mode, + }: { + serverInfo: { + capabilities?: { + voice?: { + dictation?: { enabled: boolean; reason: string }; + voice?: { enabled: boolean; reason: string }; + }; + }; + } | null; + mode: "dictation" | "voice"; + }) => serverInfo?.capabilities?.voice?.[mode] ?? null, resolveVoiceUnavailableMessage: () => null, })); @@ -554,6 +593,19 @@ beforeEach(() => { setAgentStreamTailMock.mockClear(); setAgentStreamHeadMock.mockClear(); setQueuedMessagesMock.mockClear(); + agentDirectoryStatusMock.mockReset(); + agentDirectoryStatusMock.mockReturnValue("ready"); + mockSessionState.sessions.server.serverInfo = { + serverId: "server", + hostname: "test", + version: "0.0.0", + capabilities: { + voice: { + dictation: { enabled: true, reason: "" }, + voice: { enabled: true, reason: "" }, + }, + }, + }; mockSessionState.sessions.server.agentStreamHead = new Map(); mockSessionState.sessions.server.agentStreamTail = new Map(); mockSessionState.sessions.server.queuedMessages = new Map(); @@ -878,6 +930,17 @@ describe("Composer attachments", () => { expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true); }); + it("enables dictation from server capabilities before the agent directory finishes loading", () => { + agentDirectoryStatusMock.mockReturnValue("initial_loading"); + + renderComposer(); + + expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty( + "disabled", + false, + ); + }); + it("locks the preserved draft while submit loading", () => { renderComposer({ initialText: "keep this prompt", diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 50d0ecb1b..db742ffef 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -70,6 +70,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; +import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; type QueuedMessage = { id: string; @@ -178,11 +179,11 @@ export function Composer({ const voice = useVoiceOptional(); const voiceToggleKeys = useShortcutKeys("voice-toggle"); const dictationCancelKeys = useShortcutKeys("dictation-cancel"); - const isDictationReady = - isConnected && - (agentDirectoryStatus === "ready" || - agentDirectoryStatus === "revalidating" || - agentDirectoryStatus === "error_after_ready"); + const isDictationReady = useIsDictationReady({ + serverId, + isConnected, + agentDirectoryStatus, + }); const { settings: appSettings } = useAppSettings(); diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx index fdf329e36..80f858f16 100644 --- a/packages/app/src/components/message-input.test.tsx +++ b/packages/app/src/components/message-input.test.tsx @@ -1,9 +1,15 @@ -import React from "react"; +import React, { createRef } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MessageInput, type AttachmentMenuItem } from "./message-input"; +import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input"; + +const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({ + startDictationMock: vi.fn(), + cancelDictationMock: vi.fn(), + confirmDictationMock: vi.fn(), +})); const { theme } = vi.hoisted(() => ({ theme: { @@ -85,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({ duration: 0, error: null, status: "idle", - startDictation: vi.fn(), - cancelDictation: vi.fn(), - confirmDictation: vi.fn(), + startDictation: startDictationMock, + cancelDictation: cancelDictationMock, + confirmDictation: confirmDictationMock, retryFailedDictation: vi.fn(), discardFailedDictation: vi.fn(), }), @@ -214,6 +220,9 @@ beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); + startDictationMock.mockReset(); + cancelDictationMock.mockReset(); + confirmDictationMock.mockReset(); }); afterEach(() => { @@ -325,3 +334,57 @@ describe("MessageInput attachments", () => { expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1); }); }); + +describe("MessageInput dictation shortcuts", () => { + it("does not poison the dictation toggle when readiness is temporarily false", () => { + const inputRef = createRef(); + + act(() => { + root?.render( + , + ); + }); + + act(() => { + inputRef.current?.runKeyboardAction("dictation-toggle"); + }); + expect(startDictationMock).not.toHaveBeenCalled(); + expect(confirmDictationMock).not.toHaveBeenCalled(); + + act(() => { + root?.render( + , + ); + }); + act(() => { + inputRef.current?.runKeyboardAction("dictation-toggle"); + }); + + expect(startDictationMock).toHaveBeenCalledTimes(1); + expect(confirmDictationMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 9719474a1..e83a97261 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -480,11 +480,15 @@ export const MessageInput = forwardRef(funct toast.error(dictationUnavailableMessage); return; } + if (!canStartDictation()) { + isDictatingRef.current = false; + return; + } // Keep hotkey toggling deterministic between the async start call and the // state-ref sync effect, so a rapid second toggle routes to confirm. isDictatingRef.current = true; await startDictation(); - }, [dictationUnavailableMessage, startDictation, toast]); + }, [canStartDictation, dictationUnavailableMessage, startDictation, toast]); // Animate overlay useEffect(() => { diff --git a/packages/app/src/hooks/use-is-dictation-ready.ts b/packages/app/src/hooks/use-is-dictation-ready.ts new file mode 100644 index 000000000..ee74ee388 --- /dev/null +++ b/packages/app/src/hooks/use-is-dictation-ready.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; + +import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { getVoiceReadinessState } from "@/utils/server-info-capabilities"; + +function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean { + return ( + agentDirectoryStatus === "ready" || + agentDirectoryStatus === "revalidating" || + agentDirectoryStatus === "error_after_ready" + ); +} + +export function useIsDictationReady({ + serverId, + isConnected, + agentDirectoryStatus, +}: { + serverId: string; + isConnected: boolean; + agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; +}): boolean { + const dictationCapabilityEnabled = useSessionStore( + useCallback( + (state) => { + const serverInfo = state.sessions[serverId]?.serverInfo ?? null; + return ( + getVoiceReadinessState({ + serverInfo, + mode: "dictation", + })?.enabled ?? null + ); + }, + [serverId], + ), + ); + + if (!isConnected) { + return false; + } + + return dictationCapabilityEnabled ?? isLegacyDictationReady(agentDirectoryStatus); +}