mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix dictation readiness after app refresh
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
44
packages/app/src/hooks/use-is-dictation-ready.ts
Normal file
44
packages/app/src/hooks/use-is-dictation-ready.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user