Fix dictation readiness after app refresh

This commit is contained in:
Mohamed Boudra
2026-04-20 17:23:17 +07:00
parent ac45f23213
commit 93095aa1d2
5 changed files with 187 additions and 12 deletions

View File

@@ -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<string, { status: string; lastUsage: null }>;
serverInfo: {
serverId: string;
hostname: string | null;
version: string | null;
capabilities?: {
voice?: {
dictation: { enabled: boolean; reason: string };
voice: { enabled: boolean; reason: string };
};
};
} | null;
queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>;
@@ -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",

View File

@@ -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();

View File

@@ -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<MessageInputRef>();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).not.toHaveBeenCalled();
expect(confirmDictationMock).not.toHaveBeenCalled();
act(() => {
root?.render(
<MessageInput
ref={inputRef}
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}
/>,
);
});
act(() => {
inputRef.current?.runKeyboardAction("dictation-toggle");
});
expect(startDictationMock).toHaveBeenCalledTimes(1);
expect(confirmDictationMock).not.toHaveBeenCalled();
});
});

View File

@@ -480,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(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(() => {

View File

@@ -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);
}