From 32adc0a153051ecde63f47a6df59751807355b2b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 05:15:02 -0700 Subject: [PATCH] FN-8573: add voice dictation controls to composers Add shared voice dictation support across chat, planning, and task-creation composers. - Add browser speech-recognition and composer insertion hooks. - Provide a reusable mic button with accessible recording states. - Integrate and test dictation across all supported composer surfaces. - Document the feature and add a package changeset. Files changed: .changeset/fn-8573-voice-dictation-mic.md | 7 + docs/dashboard-guide.md | 6 + packages/dashboard/app/components/ChatView.tsx | 44 +++- .../dashboard/app/components/ComposeChatPanel.tsx | 8 +- packages/dashboard/app/components/MicButton.css | 5 + packages/dashboard/app/components/MicButton.tsx | 20 ++ .../dashboard/app/components/PlanningModeModal.tsx | 82 ++++-- .../dashboard/app/components/QuickEntryBox.tsx | 4 + .../app/components/StandardChatSurface.tsx | 108 +++++--- packages/dashboard/app/components/TaskChatTab.tsx | 4 + packages/dashboard/app/components/TaskComments.tsx | 66 ++--- packages/dashboard/app/components/TaskForm.tsx | 18 +- .../app/components/TaskPlannerChatTab.tsx | 7 + .../app/components/__tests__/MicButton.test.tsx | 28 +++ .../components/__tests__/QuickEntryBox.test.tsx | 5 + .../app/components/__tests__/TaskForm.test.tsx | 5 + .../app/components/__tests__/insertAtCaret.test.ts | 25 ++ .../__tests__/voice-dictation-composers.test.tsx | 230 +++++++++++++++++ packages/dashboard/app/components/insertAtCaret.ts | 20 ++ .../hooks/__tests__/useComposerDictation.test.tsx | 58 +++++ .../app/hooks/__tests__/useVoiceDictation.test.tsx | 158 ++++++++++++ .../dashboard/app/hooks/useComposerDictation.ts | 112 +++++++++ .../dashboard/app/hooks/useVoiceDictation.ts | 277 +++++++++++++++++++++ 23 files changed, 1204 insertions(+), 93 deletions(-) Fusion-Task-Id: FN-8573 Fusion-Task-Lineage: 905ae982-3e63-494f-aba8-e3665f00cdda Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8573-voice-dictation-mic.md | 7 + docs/dashboard-guide.md | 6 + .../dashboard/app/components/ChatView.tsx | 44 ++- .../app/components/ComposeChatPanel.tsx | 8 +- .../dashboard/app/components/MicButton.css | 5 + .../dashboard/app/components/MicButton.tsx | 20 ++ .../app/components/PlanningModeModal.tsx | 82 ++++-- .../app/components/QuickEntryBox.tsx | 4 + .../app/components/StandardChatSurface.tsx | 108 +++++-- .../dashboard/app/components/TaskChatTab.tsx | 4 + .../dashboard/app/components/TaskComments.tsx | 66 +++-- .../dashboard/app/components/TaskForm.tsx | 18 +- .../app/components/TaskPlannerChatTab.tsx | 7 + .../components/__tests__/MicButton.test.tsx | 28 ++ .../__tests__/QuickEntryBox.test.tsx | 5 + .../components/__tests__/TaskForm.test.tsx | 5 + .../__tests__/insertAtCaret.test.ts | 25 ++ .../voice-dictation-composers.test.tsx | 230 +++++++++++++++ .../dashboard/app/components/insertAtCaret.ts | 20 ++ .../__tests__/useComposerDictation.test.tsx | 58 ++++ .../__tests__/useVoiceDictation.test.tsx | 158 ++++++++++ .../app/hooks/useComposerDictation.ts | 112 +++++++ .../dashboard/app/hooks/useVoiceDictation.ts | 277 ++++++++++++++++++ 23 files changed, 1204 insertions(+), 93 deletions(-) create mode 100644 .changeset/fn-8573-voice-dictation-mic.md create mode 100644 packages/dashboard/app/components/MicButton.css create mode 100644 packages/dashboard/app/components/MicButton.tsx create mode 100644 packages/dashboard/app/components/__tests__/MicButton.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/insertAtCaret.test.ts create mode 100644 packages/dashboard/app/components/__tests__/voice-dictation-composers.test.tsx create mode 100644 packages/dashboard/app/components/insertAtCaret.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useComposerDictation.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/useVoiceDictation.test.tsx create mode 100644 packages/dashboard/app/hooks/useComposerDictation.ts create mode 100644 packages/dashboard/app/hooks/useVoiceDictation.ts diff --git a/.changeset/fn-8573-voice-dictation-mic.md b/.changeset/fn-8573-voice-dictation-mic.md new file mode 100644 index 0000000000..e05c5b35b5 --- /dev/null +++ b/.changeset/fn-8573-voice-dictation-mic.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add fail-closed voice dictation controls to dashboard composers. +category: feature +dev: Shared useVoiceDictation, useComposerDictation, and MicButton honor voiceInput.enabled. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f330d2cd36..378ef9e340 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2281,6 +2281,12 @@ Custom workflow authors can add optional explanatory copy beneath each column na In plan review, select text inside the rendered plan and choose **Add comment to selection**. On mobile widths through 768px, the selection action appears in the bottom plan-action rail beside **Refine** and **Proceed with plan**; at 769px and wider it stays beside the selected plan content. Enter a suggestion to capture the selected quote and suggestion as a pending contextual comment. You can remove individual comments before choosing **Submit comments**; Fusion sends the ordered batch through the existing Planning Mode revision generation, so the agent revises the quoted areas while preserving unaffected plan content. A successful revised-plan update clears the batch; a failed submission retains it for retry. +## Voice dictation + +When **voiceInput.enabled** is enabled and the installed speech-to-text runtime confirms it is available, dashboard chat, Planning Mode, quick task entry, task forms, and task comments expose a microphone control beside their primary composer. The control is intentionally absent—not disabled—while voice input is off, status is still loading, status fails, or the runtime/model is unavailable. + +Dictation inserts a live partial transcript at the current caret (or replaces the current selection). Later partials and the final transcript replace that anchored preview in place, preserving surrounding text and the controlled textarea cursor. The mic button has accessible start/stop/error labels and announces its state for screen readers. + ### Conversation tags Direct conversations can be organized with reusable tags. Open a conversation's **More** menu to create a tag or toggle its assignments; a conversation can have multiple tags. Use the tag selector beside conversation search to filter pinned and recent conversations without affecting text search. Tags are project-scoped, and deleting a tag only removes its assignments—it never deletes conversations or messages. Chat Rooms do not use conversation tags. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 0184b980e6..90716560c2 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -26,10 +26,12 @@ import { import { FN_AGENT_ID, TASK_PLANNER_CHAT_AGENT_ID_PREFIX, useChat, type ChatMessageInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; import { useChatUnread } from "../hooks/useChatUnread"; +import { useComposerDictation } from "../hooks/useComposerDictation"; import { useViewportMode } from "./Header"; import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api"; import { type Agent, type ChatTag, type Settings } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; +import { MicButton } from "./MicButton"; import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl"; import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentAvatar } from "./AgentAvatar"; @@ -814,6 +816,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // quick re-tap never scrolls the document while iOS is raising the keyboard. const blurScrollResetTimeoutRef = useRef(null); const inputRef = useRef(null); + const roomInputRef = useRef(null); + // FNXC:VoiceInput 2026-07-24-04:10: + // ChatView can mount direct and room composers together, so each owns a ref and dictation + // adapter; a shared anchor would route a transcript into whichever textarea rendered last. const appliedComposerDraftNonceRef = useRef(undefined); const focusComposerAfterPrefillRef = useRef(false); const fileInputRef = useRef(null); @@ -1726,17 +1732,38 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout composer.style.overflowY = resolveChatInputOverflowY(composer.scrollHeight, effectiveMax); }, [mode]); + // FNXC:VoiceInput 2026-07-24-05:00: Dictation uses this same post-render resize path as + // keyboard input, including the independently mounted room composer. + const composerDictation = useComposerDictation({ + textareaRef: inputRef, + value: messageInput, + onChange: setMessageInput, + onResize: () => resizeComposer(inputRef.current), + projectId, + }); + const roomComposerDictation = useComposerDictation({ + textareaRef: roomInputRef, + value: messageInput, + onChange: setMessageInput, + onResize: () => resizeComposer(roomInputRef.current), + projectId, + }); + const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => { inputRef.current = textarea; - if (!textarea) { - return; - } - + if (!textarea) return; + resizeComposer(textarea); + }, [resizeComposer]); + const handleRoomComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => { + roomInputRef.current = textarea; + if (!textarea) return; resizeComposer(textarea); }, [resizeComposer]); useLayoutEffect(() => { - resizeComposer(); + // FNXC:VoiceInput 2026-07-24-05:00: Select the active textarea explicitly so controlled + // programmatic updates, including dictation, resize the room composer instead of a hidden direct input. + resizeComposer(chatScope === "rooms" ? roomInputRef.current : inputRef.current); if (focusComposerAfterPrefillRef.current) { focusComposerAfterPrefillRef.current = false; inputRef.current?.focus(); @@ -2856,6 +2883,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout activeModelTag={activeModelTag} activeModelProvider={activeModelProvider} activeSessionId={activeSession?.id ?? null} + projectId={projectId} mentionAgentsByName={mentionAgentsByName} roomContext={null} copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined} @@ -2901,6 +2929,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout activeModelTag={activeModelTag} activeModelProvider={activeModelProvider} activeSessionId={activeSession?.id ?? null} + projectId={projectId} mentionAgentsByName={mentionAgentsByName} roomContext={null} copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined} @@ -3136,6 +3165,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout loading={fileMention.loading} /> + 0)} @@ -3965,6 +3995,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout activeModelTag={null} activeModelProvider={null} activeSessionId={rooms.activeRoom?.id ?? null} + projectId={projectId} mentionAgentsByName={mentionAgentsByName} roomContext={roomContext} onScrollToTop={handleScrollMessageToTop} @@ -4072,7 +4103,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }} >