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:
gsxdsm
2026-07-25 05:48:35 -07:00
parent 32adc0a153
commit 9575e4b493
6 changed files with 360 additions and 44 deletions

View 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.

View File

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

View File

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

View File

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

View 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();
}

View File

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