FN-8580: deduplicate voice availability settings reads
Voice dictation now shares a project-scoped availability request without fetching settings separately. - Add a deduplicated voice availability hook for status and browser support - Update voice dictation to consume shared availability state - Cover composer settings isolation and voice availability behavior - Add a patch changeset for duplicate settings request fixes Files changed: .changeset/fn-8580-voice-settings-read-dedupe.md | 7 + .../composer-settings-read-isolation.test.tsx | 200 +++++++++++++++++++++ .../hooks/__tests__/useVoiceAvailability.test.tsx | 70 ++++++++ .../app/hooks/__tests__/useVoiceDictation.test.tsx | 27 ++- .../dashboard/app/hooks/useVoiceAvailability.ts | 69 +++++++ packages/dashboard/app/hooks/useVoiceDictation.ts | 31 +--- 6 files changed, 360 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-8580 Fusion-Task-Lineage: fac6f7f6-dda9-45d8-b501-d3762dca26df Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8580-voice-settings-read-dedupe.md
Normal file
7
.changeset/fn-8580-voice-settings-read-dedupe.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix duplicate project-settings requests when voice dictation is present in task composers.
|
||||||
|
category: fix
|
||||||
|
dev: useVoiceDictation now uses project-scoped useVoiceAvailability and no longer calls fetchSettings; it reuses health.ts withProjectId.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { TaskForm } from "../TaskForm";
|
||||||
|
import { QuickEntryBox } from "../QuickEntryBox";
|
||||||
|
import { TaskComments } from "../TaskComments";
|
||||||
|
import { fetchSettings } from "../../api";
|
||||||
|
import { __resetVoiceAvailabilityCache } from "../../hooks/useVoiceAvailability";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchModels: vi.fn().mockResolvedValue({
|
||||||
|
models: [{ provider: "anthropic", id: "model", name: "Model", reasoning: false, contextWindow: 128_000 }],
|
||||||
|
favoriteProviders: [],
|
||||||
|
favoriteModels: [],
|
||||||
|
}),
|
||||||
|
fetchSettings: vi.fn(),
|
||||||
|
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
refineText: vi.fn().mockResolvedValue(""),
|
||||||
|
getRefineErrorMessage: vi.fn(() => "Failed to refine text."),
|
||||||
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||||
|
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||||
|
addSteeringComment: vi.fn(),
|
||||||
|
updateTaskComment: vi.fn(),
|
||||||
|
deleteTaskComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PROJECT_ID = "voice-settings-project";
|
||||||
|
const ORIGINAL_INNER_WIDTH = Object.getOwnPropertyDescriptor(window, "innerWidth");
|
||||||
|
const ORIGINAL_MATCH_MEDIA = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
||||||
|
|
||||||
|
function renderTaskForm() {
|
||||||
|
return render(
|
||||||
|
<TaskForm
|
||||||
|
mode="create"
|
||||||
|
description=""
|
||||||
|
onDescriptionChange={vi.fn()}
|
||||||
|
dependencies={[]}
|
||||||
|
onDependenciesChange={vi.fn()}
|
||||||
|
executorModel=""
|
||||||
|
onExecutorModelChange={vi.fn()}
|
||||||
|
validatorModel=""
|
||||||
|
onValidatorModelChange={vi.fn()}
|
||||||
|
presetMode="default"
|
||||||
|
onPresetModeChange={vi.fn()}
|
||||||
|
selectedPresetId=""
|
||||||
|
onSelectedPresetIdChange={vi.fn()}
|
||||||
|
pendingImages={[]}
|
||||||
|
onImagesChange={vi.fn()}
|
||||||
|
tasks={[]}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
isActive
|
||||||
|
reviewLevel={undefined}
|
||||||
|
onReviewLevelChange={vi.fn()}
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuickEntryBox() {
|
||||||
|
return render(
|
||||||
|
<QuickEntryBox
|
||||||
|
onCreate={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
tasks={[]}
|
||||||
|
availableModels={[]}
|
||||||
|
onSubtaskBreakdown={vi.fn()}
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMobileViewport() {
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn((query: string) => ({
|
||||||
|
matches: query.includes("max-width") || query.includes("768"),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreViewport() {
|
||||||
|
if (ORIGINAL_INNER_WIDTH) Object.defineProperty(window, "innerWidth", ORIGINAL_INNER_WIDTH);
|
||||||
|
if (ORIGINAL_MATCH_MEDIA) Object.defineProperty(window, "matchMedia", ORIGINAL_MATCH_MEDIA);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentTask: Task = {
|
||||||
|
id: "FN-8580",
|
||||||
|
title: "Voice comments",
|
||||||
|
description: "",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
comments: [{ id: "comment-1", author: "user", text: "Editable comment", createdAt: "2026-07-25T00:00:00.000Z" }],
|
||||||
|
createdAt: "2026-07-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("composer settings-read isolation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreViewport();
|
||||||
|
__resetVoiceAvailabilityCache();
|
||||||
|
vi.mocked(fetchSettings).mockReset();
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValue({} as never);
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
runtime: { status: "available" },
|
||||||
|
model: { status: "installed" },
|
||||||
|
}))));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps TaskForm's one configured settings response for its real preset picker", async () => {
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||||
|
modelPresets: [{ id: "saved", name: "Saved preset", executorProvider: "anthropic", executorModelId: "model" }],
|
||||||
|
autoSelectModelPreset: false,
|
||||||
|
defaultPresetBySize: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
renderTaskForm();
|
||||||
|
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||||
|
|
||||||
|
const preset = await screen.findByLabelText("Preset") as HTMLSelectElement;
|
||||||
|
await waitFor(() => expect(Array.from(preset.options).some((option) => option.value === "saved")).toBe(true));
|
||||||
|
expect(fetchSettings).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/voice/status?projectId=voice-settings-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps QuickEntryBox's one configured settings response for the desktop GitHub default", async () => {
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({ githubTrackingEnabledByDefault: true } as never);
|
||||||
|
|
||||||
|
renderQuickEntryBox();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("quick-entry-github-toggle")).toHaveAttribute("aria-pressed", "true"));
|
||||||
|
expect(fetchSettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves QuickEntryBox's configured GitHub default on the mobile touch layout", async () => {
|
||||||
|
setMobileViewport();
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({ githubTrackingEnabledByDefault: true } as never);
|
||||||
|
|
||||||
|
renderQuickEntryBox();
|
||||||
|
|
||||||
|
const toggle = await screen.findByTestId("quick-entry-github-toggle");
|
||||||
|
await waitFor(() => expect(toggle).toHaveAttribute("aria-pressed", "true"));
|
||||||
|
fireEvent.pointerDown(toggle, { pointerType: "touch" });
|
||||||
|
expect(toggle).toBeInTheDocument();
|
||||||
|
expect(fetchSettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps K real settings-reading hosts at K reads", async () => {
|
||||||
|
render(<><TaskForm
|
||||||
|
mode="create"
|
||||||
|
description=""
|
||||||
|
onDescriptionChange={vi.fn()}
|
||||||
|
dependencies={[]}
|
||||||
|
onDependenciesChange={vi.fn()}
|
||||||
|
executorModel=""
|
||||||
|
onExecutorModelChange={vi.fn()}
|
||||||
|
validatorModel=""
|
||||||
|
onValidatorModelChange={vi.fn()}
|
||||||
|
presetMode="default"
|
||||||
|
onPresetModeChange={vi.fn()}
|
||||||
|
selectedPresetId=""
|
||||||
|
onSelectedPresetIdChange={vi.fn()}
|
||||||
|
pendingImages={[]}
|
||||||
|
onImagesChange={vi.fn()}
|
||||||
|
tasks={[]}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
isActive
|
||||||
|
reviewLevel={undefined}
|
||||||
|
onReviewLevelChange={vi.fn()}
|
||||||
|
projectId={PROJECT_ID}
|
||||||
|
/><QuickEntryBox onCreate={vi.fn().mockResolvedValue(undefined)} addToast={vi.fn()} tasks={[]} availableModels={[]} onSubtaskBreakdown={vi.fn()} projectId={PROJECT_ID} /></>);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps TaskComments' two real dictation consumers free of settings reads", async () => {
|
||||||
|
render(<TaskComments task={commentTask} addToast={vi.fn()} currentAuthor="user" projectId={PROJECT_ID} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(fetchSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { __resetVoiceAvailabilityCache, useVoiceAvailability } from "../useVoiceAvailability";
|
||||||
|
|
||||||
|
function Harness({ projectId, label }: { projectId?: string; label: string }) {
|
||||||
|
const availability = useVoiceAvailability(projectId);
|
||||||
|
return <output data-testid={label}>{JSON.stringify(availability)}</output>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((next) => { resolve = next; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useVoiceAvailability", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetVoiceAvailabilityCache();
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("deduplicates concurrent project requests and evicts settled entries", async () => {
|
||||||
|
const first = deferred<Response>();
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(first.promise).mockResolvedValue(new Response(JSON.stringify({ enabled: false })));
|
||||||
|
const view = render(<><Harness projectId="alpha" label="first" /><Harness projectId="alpha" label="second" /></>);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/voice/status?projectId=alpha");
|
||||||
|
first.resolve(new Response(JSON.stringify({ enabled: true })));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("first").textContent).toContain('"enabled":true'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("second").textContent).toContain('"enabled":true'));
|
||||||
|
view.unmount();
|
||||||
|
render(<Harness projectId="alpha" label="later" />);
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps projects isolated by their scoped status URLs and results", async () => {
|
||||||
|
vi.mocked(fetch).mockImplementation(async (url) => new Response(JSON.stringify({ enabled: String(url).includes("alpha") })));
|
||||||
|
render(<><Harness projectId="alpha" label="alpha" /><Harness projectId="beta value" label="beta" /><Harness label="default" /></>);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("alpha").textContent).toContain('"enabled":true'));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("beta").textContent).toContain('"enabled":false'));
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/voice/status?projectId=alpha");
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/voice/status?projectId=beta%20value");
|
||||||
|
expect(fetch).toHaveBeenCalledWith("/api/voice/status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on rejected, non-OK, and malformed status responses", async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce(new Response("", { status: 503 })).mockResolvedValueOnce(new Response("not-json"));
|
||||||
|
const { rerender } = render(<Harness projectId="rejected" label="voice" />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("voice").textContent).toBe('{"enabled":false,"supported":false}'));
|
||||||
|
rerender(<Harness projectId="not-ok" label="voice" />);
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
|
rerender(<Harness projectId="malformed" label="voice" />);
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
|
||||||
|
expect(screen.getByTestId("voice").textContent).toBe('{"enabled":false,"supported":false}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not abort a shared request when one consumer departs or changes projects", async () => {
|
||||||
|
const alpha = deferred<Response>();
|
||||||
|
const beta = deferred<Response>();
|
||||||
|
vi.mocked(fetch).mockImplementation((url) => String(url).includes("alpha") ? alpha.promise : beta.promise);
|
||||||
|
const { rerender } = render(<><Harness projectId="alpha" label="departing" /><Harness projectId="alpha" label="remaining" /></>);
|
||||||
|
rerender(<><Harness projectId="beta" label="departing" /><Harness projectId="alpha" label="remaining" /></>);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
alpha.resolve(new Response(JSON.stringify({ enabled: true })));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("remaining").textContent).toContain('"enabled":true'));
|
||||||
|
beta.resolve(new Response(JSON.stringify({ enabled: false })));
|
||||||
|
await waitFor(() => expect(screen.getByTestId("departing").textContent).toContain('"enabled":false'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { useVoiceDictation } from "../useVoiceDictation";
|
import { useVoiceDictation } from "../useVoiceDictation";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({ fetchSettings: vi.fn() }));
|
import { __resetVoiceAvailabilityCache } from "../useVoiceAvailability";
|
||||||
import { fetchSettings } from "../../api";
|
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const voice = useVoiceDictation();
|
const voice = useVoiceDictation();
|
||||||
@@ -15,7 +14,6 @@ function Harness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function availableResponses() {
|
function availableResponses() {
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: true } } as never);
|
|
||||||
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
||||||
@@ -42,25 +40,26 @@ function installAudioCapture() {
|
|||||||
|
|
||||||
describe("useVoiceDictation", () => {
|
describe("useVoiceDictation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchSettings).mockReset();
|
__resetVoiceAvailabilityCache();
|
||||||
vi.stubGlobal("fetch", vi.fn());
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
});
|
});
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it("fails closed while status is pending or fails", async () => {
|
it("fails closed while status is pending or fails", async () => {
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: true } } as never);
|
vi.mocked(fetch).mockRejectedValue(new Error("offline"));
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error("offline"));
|
|
||||||
render(<Harness />);
|
render(<Harness />);
|
||||||
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
|
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
|
||||||
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"enabled":true'));
|
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledWith("/api/voice/status"));
|
||||||
|
expect(screen.getByTestId("voice").textContent).toContain('"enabled":false');
|
||||||
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
|
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not request status while voice is disabled", async () => {
|
it("keeps capture unavailable while voice status is disabled", async () => {
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: false } } as never);
|
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ enabled: false, runtime: { status: "available" }, model: { status: "installed" } })));
|
||||||
render(<Harness />);
|
render(<Harness />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledWith("/api/voice/status"));
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
expect(screen.getByTestId("voice").textContent).toContain('"enabled":false');
|
||||||
|
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails closed when AudioWorkletNode is unavailable", async () => {
|
it("fails closed when AudioWorkletNode is unavailable", async () => {
|
||||||
@@ -95,8 +94,7 @@ describe("useVoiceDictation", () => {
|
|||||||
|
|
||||||
it("releases microphone tracks immediately when an in-flight transcription never settles", async () => {
|
it("releases microphone tracks immediately when an in-flight transcription never settles", async () => {
|
||||||
const { tracks, port } = installAudioCapture();
|
const { tracks, port } = installAudioCapture();
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: true } } as never);
|
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
||||||
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
||||||
if (url === "/api/voice/session") return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
|
if (url === "/api/voice/session") return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
|
||||||
@@ -120,8 +118,7 @@ describe("useVoiceDictation", () => {
|
|||||||
it("bounds a stalled pre-stop flush, aborts its request, and deletes only that session", async () => {
|
it("bounds a stalled pre-stop flush, aborts its request, and deletes only that session", async () => {
|
||||||
const { port } = installAudioCapture();
|
const { port } = installAudioCapture();
|
||||||
let stalledSignal: AbortSignal | undefined;
|
let stalledSignal: AbortSignal | undefined;
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: true } } as never);
|
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
||||||
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
if (url === "/api/voice/status") return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
|
||||||
if (url === "/api/voice/session") return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
|
if (url === "/api/voice/session") return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
|
||||||
|
|||||||
69
packages/dashboard/app/hooks/useVoiceAvailability.ts
Normal file
69
packages/dashboard/app/hooks/useVoiceAvailability.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { withProjectId } from "../api/health.js";
|
||||||
|
|
||||||
|
type VoiceStatus = {
|
||||||
|
enabled?: boolean;
|
||||||
|
runtime?: { status?: string };
|
||||||
|
model?: { status?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VoiceAvailability = { enabled: boolean; supported: boolean };
|
||||||
|
|
||||||
|
const unavailable: VoiceAvailability = { enabled: false, supported: false };
|
||||||
|
const inFlight = new Map<string, Promise<VoiceStatus>>();
|
||||||
|
|
||||||
|
function canCapture(): boolean {
|
||||||
|
return typeof navigator !== "undefined"
|
||||||
|
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
|
&& Boolean((window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext))
|
||||||
|
&& typeof AudioWorkletNode !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(url: string): Promise<VoiceStatus> {
|
||||||
|
const existing = inFlight.get(url);
|
||||||
|
if (existing) return existing;
|
||||||
|
const request = fetch(url)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error("Voice status unavailable");
|
||||||
|
return await response.json() as VoiceStatus;
|
||||||
|
})
|
||||||
|
.finally(() => inFlight.delete(url));
|
||||||
|
inFlight.set(url, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:VoiceInput 2026-07-25-05:45:
|
||||||
|
* The voice path must not call fetchSettings: /api/voice/status already resolves the merged enable
|
||||||
|
* flag, while a second settings consumer steals a host composer's mockResolvedValueOnce response and
|
||||||
|
* duplicates production settings reads. Keep the status URL project-scoped through health's
|
||||||
|
* withProjectId helper for fetchSettings parity, and keep shared request cancellation cache-owned
|
||||||
|
* rather than letting an individual consumer abort work another live consumer depends on.
|
||||||
|
*/
|
||||||
|
export function useVoiceAvailability(projectId?: string): VoiceAvailability {
|
||||||
|
const [availability, setAvailability] = useState<VoiceAvailability>(unavailable);
|
||||||
|
const url = withProjectId("/api/voice/status", projectId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stale = false;
|
||||||
|
setAvailability(unavailable);
|
||||||
|
void getStatus(url).then((status) => {
|
||||||
|
if (stale) return;
|
||||||
|
const enabled = status.enabled === true;
|
||||||
|
setAvailability({
|
||||||
|
enabled,
|
||||||
|
supported: enabled && canCapture() && status.runtime?.status === "available" && status.model?.status === "installed",
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
if (!stale) setAvailability(unavailable);
|
||||||
|
});
|
||||||
|
return () => { stale = true; };
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: requests are intentionally deduplicated only while in flight. */
|
||||||
|
export function __resetVoiceAvailabilityCache() {
|
||||||
|
inFlight.clear();
|
||||||
|
}
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchSettings } from "../api";
|
import { useVoiceAvailability } from "./useVoiceAvailability";
|
||||||
|
|
||||||
export type VoiceDictationState = "idle" | "listening" | "transcribing" | "error";
|
export type VoiceDictationState = "idle" | "listening" | "transcribing" | "error";
|
||||||
type VoiceStatus = { enabled?: boolean; runtime?: { status?: string }; model?: { status?: string } };
|
|
||||||
|
|
||||||
function canCapture(): boolean {
|
|
||||||
return typeof navigator !== "undefined"
|
|
||||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
|
||||||
&& Boolean((window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext))
|
|
||||||
// FNXC:VoiceInput 2026-07-24-05:30: This capture path constructs an AudioWorkletNode,
|
|
||||||
// so a browser without that constructor is unavailable before the mic button may render.
|
|
||||||
&& typeof AudioWorkletNode !== "undefined";
|
|
||||||
}
|
|
||||||
function base64(bytes: ArrayBuffer): string {
|
function base64(bytes: ArrayBuffer): string {
|
||||||
let value = ""; for (const byte of new Uint8Array(bytes)) value += String.fromCharCode(byte);
|
let value = ""; for (const byte of new Uint8Array(bytes)) value += String.fromCharCode(byte);
|
||||||
return btoa(value);
|
return btoa(value);
|
||||||
@@ -25,8 +15,7 @@ function base64(bytes: ArrayBuffer): string {
|
|||||||
* Stale responses are ignored after stop so capture tracks can always be released without late text.
|
* Stale responses are ignored after stop so capture tracks can always be released without late text.
|
||||||
*/
|
*/
|
||||||
export function useVoiceDictation(projectId?: string) {
|
export function useVoiceDictation(projectId?: string) {
|
||||||
const [enabled, setEnabled] = useState(false);
|
const { enabled, supported } = useVoiceAvailability(projectId);
|
||||||
const [supported, setSupported] = useState(false);
|
|
||||||
const [state, setState] = useState<VoiceDictationState>("idle");
|
const [state, setState] = useState<VoiceDictationState>("idle");
|
||||||
const [partialText, setPartialText] = useState("");
|
const [partialText, setPartialText] = useState("");
|
||||||
const [finalText, setFinalText] = useState("");
|
const [finalText, setFinalText] = useState("");
|
||||||
@@ -89,22 +78,6 @@ export function useVoiceDictation(projectId?: string) {
|
|||||||
}
|
}
|
||||||
}, [releaseCapture]);
|
}, [releaseCapture]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setSupported(false); setEnabled(false);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const settings = await fetchSettings(projectId);
|
|
||||||
if (controller.signal.aborted || settings.voiceInput?.enabled !== true) return;
|
|
||||||
setEnabled(true);
|
|
||||||
const response = await fetch("/api/voice/status", { signal: controller.signal });
|
|
||||||
if (!response.ok) return;
|
|
||||||
const status = await response.json() as VoiceStatus;
|
|
||||||
if (!controller.signal.aborted && canCapture() && status.enabled === true && status.runtime?.status === "available" && status.model?.status === "installed") setSupported(true);
|
|
||||||
} catch { /* availability deliberately remains false */ }
|
|
||||||
})();
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [projectId]);
|
|
||||||
useEffect(() => release, [release]);
|
useEffect(() => release, [release]);
|
||||||
|
|
||||||
const fail = useCallback((message: string) => { release(); setError(message); setState("error"); }, [release]);
|
const fail = useCallback((message: string) => { release(); setError(message); setState("error"); }, [release]);
|
||||||
|
|||||||
Reference in New Issue
Block a user