diff --git a/.changeset/fn-8580-voice-settings-read-dedupe.md b/.changeset/fn-8580-voice-settings-read-dedupe.md
new file mode 100644
index 0000000000..3dd2638b92
--- /dev/null
+++ b/.changeset/fn-8580-voice-settings-read-dedupe.md
@@ -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.
diff --git a/packages/dashboard/app/components/__tests__/composer-settings-read-isolation.test.tsx b/packages/dashboard/app/components/__tests__/composer-settings-read-isolation.test.tsx
new file mode 100644
index 0000000000..2c24b16cf2
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/composer-settings-read-isolation.test.tsx
@@ -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(
+ ,
+ );
+}
+
+function renderQuickEntryBox() {
+ return render(
+ ,
+ );
+}
+
+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(<>>);
+
+ await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps TaskComments' two real dictation consumers free of settings reads", async () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Edit" }));
+
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
+ expect(fetchSettings).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useVoiceAvailability.test.tsx b/packages/dashboard/app/hooks/__tests__/useVoiceAvailability.test.tsx
new file mode 100644
index 0000000000..07d917762d
--- /dev/null
+++ b/packages/dashboard/app/hooks/__tests__/useVoiceAvailability.test.tsx
@@ -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 ;
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((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();
+ vi.mocked(fetch).mockResolvedValueOnce(first.promise).mockResolvedValue(new Response(JSON.stringify({ enabled: false })));
+ const view = render(<>>);
+ 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();
+ 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(<>>);
+ 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();
+ await waitFor(() => expect(screen.getByTestId("voice").textContent).toBe('{"enabled":false,"supported":false}'));
+ rerender();
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ rerender();
+ 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();
+ const beta = deferred();
+ vi.mocked(fetch).mockImplementation((url) => String(url).includes("alpha") ? alpha.promise : beta.promise);
+ const { rerender } = render(<>>);
+ rerender(<>>);
+ 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'));
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useVoiceDictation.test.tsx b/packages/dashboard/app/hooks/__tests__/useVoiceDictation.test.tsx
index a0911e57e9..a20c393684 100644
--- a/packages/dashboard/app/hooks/__tests__/useVoiceDictation.test.tsx
+++ b/packages/dashboard/app/hooks/__tests__/useVoiceDictation.test.tsx
@@ -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();
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();
- 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 });
diff --git a/packages/dashboard/app/hooks/useVoiceAvailability.ts b/packages/dashboard/app/hooks/useVoiceAvailability.ts
new file mode 100644
index 0000000000..96bc72fc65
--- /dev/null
+++ b/packages/dashboard/app/hooks/useVoiceAvailability.ts
@@ -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>();
+
+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 {
+ 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(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();
+}
diff --git a/packages/dashboard/app/hooks/useVoiceDictation.ts b/packages/dashboard/app/hooks/useVoiceDictation.ts
index b1c5234b60..2c5b4b554e 100644
--- a/packages/dashboard/app/hooks/useVoiceDictation.ts
+++ b/packages/dashboard/app/hooks/useVoiceDictation.ts
@@ -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("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]);