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 }} >