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 {JSON.stringify(availability)}; +} + +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]);