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 { useVoiceDictation } from "../useVoiceDictation";
|
||||
|
||||
vi.mock("../../api", () => ({ fetchSettings: vi.fn() }));
|
||||
import { fetchSettings } from "../../api";
|
||||
import { __resetVoiceAvailabilityCache } from "../useVoiceAvailability";
|
||||
|
||||
function Harness() {
|
||||
const voice = useVoiceDictation();
|
||||
@@ -15,7 +14,6 @@ function Harness() {
|
||||
}
|
||||
|
||||
function availableResponses() {
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: true } } as never);
|
||||
vi.mocked(fetch).mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchSettings).mockReset();
|
||||
__resetVoiceAvailabilityCache();
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
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 />);
|
||||
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');
|
||||
});
|
||||
|
||||
it("does not request status while voice is disabled", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValue({ voiceInput: { enabled: false } } as never);
|
||||
it("keeps capture unavailable while voice status is disabled", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ enabled: false, runtime: { status: "available" }, model: { status: "installed" } })));
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
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 });
|
||||
@@ -120,8 +118,7 @@ describe("useVoiceDictation", () => {
|
||||
it("bounds a stalled pre-stop flush, aborts its request, and deletes only that session", async () => {
|
||||
const { port } = installAudioCapture();
|
||||
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);
|
||||
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 });
|
||||
|
||||
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 { fetchSettings } from "../api";
|
||||
import { useVoiceAvailability } from "./useVoiceAvailability";
|
||||
|
||||
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 {
|
||||
let value = ""; for (const byte of new Uint8Array(bytes)) value += String.fromCharCode(byte);
|
||||
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.
|
||||
*/
|
||||
export function useVoiceDictation(projectId?: string) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [supported, setSupported] = useState(false);
|
||||
const { enabled, supported } = useVoiceAvailability(projectId);
|
||||
const [state, setState] = useState<VoiceDictationState>("idle");
|
||||
const [partialText, setPartialText] = useState("");
|
||||
const [finalText, setFinalText] = useState("");
|
||||
@@ -89,22 +78,6 @@ export function useVoiceDictation(projectId?: string) {
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
const fail = useCallback((message: string) => { release(); setError(message); setState("error"); }, [release]);
|
||||
|
||||
Reference in New Issue
Block a user