Fix dictation readiness after app refresh
This commit is contained in:
@@ -25,6 +25,7 @@ const {
|
|||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const theme = {
|
const theme = {
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||||
@@ -103,6 +104,17 @@ const {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
agents: Map<string, { status: string; lastUsage: null }>;
|
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[]>;
|
queuedMessages: Map<string, unknown[]>;
|
||||||
agentStreamHead: Map<string, unknown[]>;
|
agentStreamHead: Map<string, unknown[]>;
|
||||||
agentStreamTail: Map<string, unknown[]>;
|
agentStreamTail: Map<string, unknown[]>;
|
||||||
@@ -115,6 +127,17 @@ const {
|
|||||||
sessions: {
|
sessions: {
|
||||||
server: {
|
server: {
|
||||||
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
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(),
|
queuedMessages: new Map(),
|
||||||
agentStreamHead: new Map(),
|
agentStreamHead: new Map(),
|
||||||
agentStreamTail: new Map(),
|
agentStreamTail: new Map(),
|
||||||
@@ -137,6 +160,7 @@ const {
|
|||||||
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||||
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||||
const markScrollInvestigationRenderMock = vi.fn();
|
const markScrollInvestigationRenderMock = vi.fn();
|
||||||
|
const agentDirectoryStatusMock = vi.fn(() => "ready");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
@@ -154,6 +178,7 @@ const {
|
|||||||
setAgentStreamTailMock,
|
setAgentStreamTailMock,
|
||||||
setAgentStreamHeadMock,
|
setAgentStreamHeadMock,
|
||||||
setQueuedMessagesMock,
|
setQueuedMessagesMock,
|
||||||
|
agentDirectoryStatusMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +245,7 @@ vi.mock("react-native-safe-area-context", () => ({
|
|||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHostRuntimeClient: () => mockClient,
|
useHostRuntimeClient: () => mockClient,
|
||||||
useHostRuntimeIsConnected: () => true,
|
useHostRuntimeIsConnected: () => true,
|
||||||
useHostRuntimeAgentDirectoryStatus: () => "ready",
|
useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => {
|
vi.mock("@/stores/session-store", () => {
|
||||||
@@ -363,6 +388,20 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
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,
|
resolveVoiceUnavailableMessage: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -554,6 +593,19 @@ beforeEach(() => {
|
|||||||
setAgentStreamTailMock.mockClear();
|
setAgentStreamTailMock.mockClear();
|
||||||
setAgentStreamHeadMock.mockClear();
|
setAgentStreamHeadMock.mockClear();
|
||||||
setQueuedMessagesMock.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.agentStreamHead = new Map();
|
||||||
mockSessionState.sessions.server.agentStreamTail = new Map();
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
mockSessionState.sessions.server.queuedMessages = 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);
|
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", () => {
|
it("locks the preserved draft while submit loading", () => {
|
||||||
renderComposer({
|
renderComposer({
|
||||||
initialText: "keep this prompt",
|
initialText: "keep this prompt",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
|||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -178,11 +179,11 @@ export function Composer({
|
|||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||||
const isDictationReady =
|
const isDictationReady = useIsDictationReady({
|
||||||
isConnected &&
|
serverId,
|
||||||
(agentDirectoryStatus === "ready" ||
|
isConnected,
|
||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus,
|
||||||
agentDirectoryStatus === "error_after_ready");
|
});
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings();
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import React from "react";
|
import React, { createRef } from "react";
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
@@ -85,9 +91,9 @@ vi.mock("@/hooks/use-dictation", () => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
startDictation: vi.fn(),
|
startDictation: startDictationMock,
|
||||||
cancelDictation: vi.fn(),
|
cancelDictation: cancelDictationMock,
|
||||||
confirmDictation: vi.fn(),
|
confirmDictation: confirmDictationMock,
|
||||||
retryFailedDictation: vi.fn(),
|
retryFailedDictation: vi.fn(),
|
||||||
discardFailedDictation: vi.fn(),
|
discardFailedDictation: vi.fn(),
|
||||||
}),
|
}),
|
||||||
@@ -214,6 +220,9 @@ beforeEach(() => {
|
|||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
|
startDictationMock.mockReset();
|
||||||
|
cancelDictationMock.mockReset();
|
||||||
|
confirmDictationMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -325,3 +334,57 @@ describe("MessageInput attachments", () => {
|
|||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -480,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
toast.error(dictationUnavailableMessage);
|
toast.error(dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canStartDictation()) {
|
||||||
|
isDictatingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep hotkey toggling deterministic between the async start call and the
|
// Keep hotkey toggling deterministic between the async start call and the
|
||||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||||
isDictatingRef.current = true;
|
isDictatingRef.current = true;
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user