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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-25 05:15:02 -07:00
parent 11db36187f
commit 32adc0a153
23 changed files with 1204 additions and 93 deletions

View File

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

View File

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

View File

@@ -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<number | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const roomInputRef = useRef<HTMLTextAreaElement>(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<number | undefined>(undefined);
const focusComposerAfterPrefillRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(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}
/>
</div>
<MicButton {...composerDictation.micProps} />
<StandardChatActionButton
isStreaming={isStreaming}
canSend={Boolean(messageInput.trim() || pendingAttachments.length > 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
}}
>
<textarea
ref={handleComposerRef}
ref={handleRoomComposerRef}
className="chat-input-textarea"
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
@@ -4106,6 +4137,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
roomName={roomContext?.roomName}
/>
</div>
<MicButton {...roomComposerDictation.micProps} />
<StandardChatActionButton
isStreaming={false}
canSend={Boolean(messageInput.trim() || pendingAttachments.length > 0)}

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import type { NativeStructureEmbed } from "@fusion/core";
import { FN_AGENT_ID, useChat } from "../hooks/useChat";
import "./ComposeChatPanel.css";
@@ -30,6 +32,8 @@ export function ComposeChatPanel({ projectId, embeds, draftBody, onUseDraft, onC
const creatingSession = useRef(false);
const closed = useRef(false);
const archivedSessionId = useRef<string | null>(null);
const requestRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef: requestRef, value: request, onChange: setRequest, projectId });
const restoredSessionId = useRef<string | null>(null);
const archiveScratchSession = useCallback((id = scratchSessionId.current) => {
@@ -105,9 +109,9 @@ export function ComposeChatPanel({ projectId, embeds, draftBody, onUseDraft, onC
return (
<section id="compose-chat-panel" className="compose-chat-panel" aria-label="Compose chat narrative helper" data-testid="compose-chat-panel">
<label className="message-composer-label" htmlFor="compose-chat-request">Draft narrative</label>
<textarea id="compose-chat-request" className="input compose-chat-panel__input" value={request} onChange={(event) => setRequest(event.target.value)} />
<textarea ref={requestRef} id="compose-chat-request" className="input compose-chat-panel__input" value={request} onChange={(event) => setRequest(event.target.value)} />
<div className="compose-chat-panel__output" aria-live="polite">{latestDraft || "Ask the assistant to draft the narrative around your attached structures."}</div>
<div className="compose-chat-panel__actions">
<div className="compose-chat-panel__actions"><MicButton {...dictation.micProps} />
<button className="btn btn-sm btn-primary" type="button" onClick={() => void send()} disabled={chat.isStreaming || isCreating || hasPendingPrompt || !request.trim()}>Draft</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => latestDraft && onUseDraft(latestDraft)} disabled={!latestDraft}>Use draft</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={close}>Close</button>

View File

@@ -0,0 +1,5 @@
.mic-button { transition: color var(--transition-fast), background-color var(--transition-fast); }
.mic-button--listening { color: var(--color-error); }
.mic-button--transcribing { color: var(--color-warning); }
.mic-button--error { color: var(--color-error); }
@media (max-width: 768px) { .mic-button { min-inline-size: calc(var(--space-4) * 2 + var(--space-1)); min-block-size: calc(var(--space-4) * 2 + var(--space-1)); } }

View File

@@ -0,0 +1,20 @@
import { Mic, MicOff } from "lucide-react";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import "./MicButton.css";
type MicButtonProps = Pick<ComponentProps<"button">, "disabled"> & { enabled: boolean; supported: boolean; state: "idle" | "listening" | "transcribing" | "error"; error?: string; start: () => void | Promise<void>; stop: () => void; };
/** FNXC:VoiceInput 2026-07-24-03:10: Never leave an empty composer shell: availability is unproven until the hook confirms it. */
export function MicButton({ enabled, supported, state, error, start, stop, disabled }: MicButtonProps) {
const priorState = useRef(state);
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
if (priorState.current !== state) {
setAnnouncement(error ?? (state === "error" ? "Voice dictation error" : state === "listening" ? "Voice dictation started" : state === "idle" ? "Voice dictation stopped" : "Voice dictation transcribing"));
priorState.current = state;
}
}, [error, state]);
if (!enabled || !supported) return null;
const active = state === "listening" || state === "transcribing";
const label = state === "error" ? "Voice dictation error" : active ? "Stop voice dictation" : "Start voice dictation";
return <><button type="button" className={`btn btn-icon mic-button mic-button--${state}`} disabled={disabled} aria-label={label} onClick={() => { if (active) stop(); else void start(); }}>{state === "error" ? <MicOff aria-hidden="true" /> : <Mic aria-hidden="true" />}</button><span className="sr-only" aria-live="polite">{announcement}</span></>;
}

View File

@@ -67,6 +67,8 @@ import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea";
import { useToast } from "../hooks/useToast";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
const WARNING_ICON = "⚠️";
@@ -529,6 +531,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [generationActivity, setGenerationActivity] = useState<PlanningGenerationActivity>("initial_plan");
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const initialPlanDictation = useComposerDictation({ textareaRef, value: initialPlan, onChange: setInitialPlan, projectId });
// Align long-form planning composers with FN-5146's 640px chat convention so
// multi-paragraph drafts stay visible; SummaryView keeps a larger expanded
// cap so the two-tier collapsed/expanded editing UX remains intact.
@@ -854,6 +857,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}, [isMobile, workspaceQuestion?.id]);
const refineMenuRef = useRef<HTMLDivElement>(null);
const refinementInputRef = useRef<HTMLTextAreaElement>(null);
const refinementDictation = useComposerDictation({ textareaRef: refinementInputRef, value: refinementPrompt, onChange: setRefinementPrompt, projectId });
const refineTriggerRef = useRef<HTMLButtonElement>(null);
const { addToast } = useToast();
const { pushNav } = useNavigationHistoryContext();
@@ -3476,6 +3480,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
placeholder={t("planning.refinePromptPlaceholder", "For example: add a staged rollout, cover failure recovery, and ask about migration risks.")}
rows={4}
/>
<MicButton {...refinementDictation.micProps} />
</label>
<div className="planning-refine-menu-actions">
<button
@@ -3782,6 +3787,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}
}}
/>
<MicButton {...initialPlanDictation.micProps} />
</div>
<div className="planning-examples">
@@ -3999,6 +4005,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{workspaceQuestion && (
<section id="planning-question-panel" className="planning-question planning-question-pane" data-testid="planning-question-pane" aria-label={t("planning.currentQuestion", "Current question")}>
<QuestionForm
projectId={projectId}
question={workspaceQuestion}
initialResponse={editingQuestionId
? conversationHistory.find((entry) => entry.question?.id === editingQuestionId)?.response
@@ -4167,6 +4174,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.type === "summary" && editedSummary && (
<SummaryView
projectId={projectId}
summary={editedSummary}
historyEntries={conversationHistory}
onSummaryChange={setEditedSummary}
@@ -4219,9 +4227,12 @@ interface QuestionFormProps {
question: PlanningQuestion;
initialResponse?: QuestionResponse;
onSubmit: (responses: QuestionResponse) => void;
projectId?: string;
}
function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: QuestionFormProps) {
// FNXC:VoiceInput 2026-07-25-19:20: Export the real interview surface for dictation
// contract tests instead of substituting a fixture that could drift from this textarea.
export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit, projectId }: QuestionFormProps) {
const { t } = useTranslation("app");
const question = normalizeQuestionOptions(rawQuestion);
const questionOptions = question.options ?? [];
@@ -4236,6 +4247,12 @@ function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: Ques
maxHeight: 640,
deps: [question.id],
});
const textAnswerRef = useRef<HTMLTextAreaElement>(null);
const setTextAnswerRef = useCallback((node: HTMLTextAreaElement | null) => {
textAnswerRef.current = node;
textAnswerAutosizeRef(node);
}, [textAnswerAutosizeRef]);
const textAnswerDictation = useComposerDictation({ textareaRef: textAnswerRef, value: textValue, onChange: setTextValue, projectId });
const { ref: commentAutosizeRef } = useAutosizeTextarea({
value: commentValue,
minHeight: 80,
@@ -4356,19 +4373,22 @@ function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: Ques
<div className="planning-options">
{question.type === "text" && (
<textarea
ref={textAnswerAutosizeRef}
className="planning-textarea"
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
<>
<textarea
ref={setTextAnswerRef}
className="planning-textarea"
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
<MicButton {...textAnswerDictation.micProps} />
</>
)}
{question.type === "single_select" && (
@@ -4570,6 +4590,7 @@ function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: Ques
}
interface SummaryViewProps {
projectId?: string;
summary: PlanningSummary;
historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: PlanningSummary) => void;
@@ -4588,7 +4609,10 @@ interface SummaryViewProps {
isRefiningSummary: boolean;
}
function SummaryView({
// FNXC:VoiceInput 2026-07-25-19:20: Export the real summary surface for dictation
// contract tests instead of substituting a fixture that could drift from this textarea.
export function SummaryView({
projectId,
summary: rawSummary,
historyEntries,
onSummaryChange,
@@ -4623,6 +4647,17 @@ function SummaryView({
maxHeight: isExpanded ? 800 : 640,
deps: [isExpanded],
});
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const setDescriptionRef = useCallback((node: HTMLTextAreaElement | null) => {
descriptionRef.current = node;
descriptionAutosizeRef(node);
}, [descriptionAutosizeRef]);
const descriptionDictation = useComposerDictation({
textareaRef: descriptionRef,
value: summary.description,
onChange: (description) => onSummaryChange({ ...summary, description }),
projectId,
});
const selectedPriority = normalizeTaskPriority(summary.priority);
const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new";
const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim();
@@ -4693,13 +4728,16 @@ function SummaryView({
<ReactMarkdown remarkPlugins={[remarkGfm]}>{summary.description}</ReactMarkdown>
</div>
) : (
<textarea
id="planning-summary-description"
ref={descriptionAutosizeRef}
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
value={summary.description}
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
/>
<>
<textarea
id="planning-summary-description"
ref={setDescriptionRef}
className={`planning-textarea ${isExpanded ? "expanded" : ""}`}
value={summary.description}
onChange={(e) => onSummaryChange({ ...summary, description: e.target.value })}
/>
<MicButton {...descriptionDictation.micProps} />
</>
)}
</div>

View File

@@ -13,6 +13,8 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import { NodeHealthDot } from "./NodeHealthDot";
import { ProviderIcon } from "./ProviderIcon";
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
@@ -170,6 +172,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
// Starts expanded by default — controls visible immediately
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(defaultExpanded);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef, value: description, onChange: setDescription, projectId });
const fileInputRef = useRef<HTMLInputElement>(null);
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
const startIntentRef = useRef<ValidatedQuickAddWorkflow | null>(null);
@@ -1838,6 +1841,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
aria-expanded={isDisclosureExpanded}
/>
</div>
<MicButton {...dictation.micProps} disabled={isSubmitting || isDisabled} />
<button
type="button"
className="btn btn-sm quick-entry-toggle"

View File

@@ -13,6 +13,8 @@ import { ProviderIcon } from "./ProviderIcon";
import { NativeStructurePreview } from "./NativeStructurePreview";
import { openNativeStructure } from "./nativeStructureNavigation";
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNativeStructureChatRefMatch } from "./nativeStructureChatRef";
import { MicButton } from "./MicButton";
import { useComposerDictation } from "../hooks/useComposerDictation";
export interface StandardRoomContext {
roomName: string;
@@ -28,6 +30,8 @@ export interface StandardChatMessageItemProps {
activeModelTag: string | null;
activeModelProvider: string | null;
activeSessionId: string | null;
/** The owning dashboard project keeps voice availability isolated in multi-project views. */
projectId?: string;
mentionAgentsByName?: Map<string, Agent>;
roomContext?: StandardRoomContext | null;
copyAction?: ReactNode;
@@ -502,6 +506,69 @@ export function renderStandardAssistantContent(content: string, forcePlain: bool
);
}
/**
* FNXC:VoiceInput 2026-07-25-04:15:
* Mount dictation only while the correction textarea is open. Message rows must not each poll
* voice availability while merely rendering history; this editor remains the shared Quick Chat path.
*/
function StandardChatMessageEditComposer({
value,
onChange,
onCancel,
onSave,
disabled,
saveDisabled,
messageId,
projectId,
}: {
value: string;
onChange: (value: string) => void;
onCancel: () => void;
onSave: () => void;
disabled: boolean;
saveDisabled: boolean;
messageId: string;
projectId?: string;
}) {
const { t } = useTranslation("app");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// FNXC:VoiceInput 2026-07-25-12:15: Message correction dictation must resolve availability
// within the owning project; falling back to another project's settings can expose the mic incorrectly.
const dictation = useComposerDictation({ textareaRef, value, onChange, projectId });
useEffect(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
}, []);
return (
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${messageId}`}>
<textarea
ref={textareaRef}
className="input chat-message-edit-textarea"
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onSave();
}
}}
rows={3}
/>
<div className="chat-message-edit-actions">
<MicButton {...dictation.micProps} disabled={disabled} />
<button type="button" className="btn btn-sm" data-testid={`chat-message-edit-cancel-${messageId}`} disabled={disabled} onClick={onCancel}>{t("chat.editMessageCancel", "Cancel")}</button>
<button type="button" className="btn btn-sm btn-primary" data-testid={`chat-message-edit-save-${messageId}`} disabled={saveDisabled} onClick={onSave}>{t("chat.editMessageSave", "Save")}</button>
</div>
</div>
);
}
export const StandardChatMessageItem = memo(function StandardChatMessageItem({
message,
forcePlain,
@@ -522,6 +589,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
onEditMessage,
canEdit = false,
isTopClipped = false,
projectId,
}: StandardChatMessageItemProps) {
const { t } = useTranslation("app");
const isAssistantMessage = message.role === "assistant";
@@ -538,7 +606,6 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
const [isEditing, setIsEditing] = useState(false);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [editedText, setEditedText] = useState(message.content);
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
const startEditing = useCallback(() => {
setEditedText(message.content);
@@ -572,12 +639,6 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
}
}, [editedText, isSavingEdit, message.content, message.id, onEditMessage]);
useEffect(() => {
if (isEditing) {
editTextareaRef.current?.focus();
editTextareaRef.current?.select();
}
}, [isEditing]);
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
/*
* FNXC:ChatEmptyMessage 2026-07-10-00:00:
@@ -661,29 +722,16 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
{showAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
{isEditing ? (
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${message.id}`}>
<textarea
ref={editTextareaRef}
className="input chat-message-edit-textarea"
value={editedText}
disabled={isSavingEdit}
onChange={(event) => setEditedText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelEditing();
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void saveEdit();
}
}}
rows={3}
/>
<div className="chat-message-edit-actions">
<button type="button" className="btn btn-sm" data-testid={`chat-message-edit-cancel-${message.id}`} disabled={isSavingEdit} onClick={cancelEditing}>{t("chat.editMessageCancel", "Cancel")}</button>
<button type="button" className="btn btn-sm btn-primary" data-testid={`chat-message-edit-save-${message.id}`} disabled={isSavingEdit || !editedText.trim() || editedText.trim() === message.content.trim()} onClick={() => void saveEdit()}>{t("chat.editMessageSave", "Save")}</button>
</div>
</div>
<StandardChatMessageEditComposer
value={editedText}
onChange={setEditedText}
onCancel={cancelEditing}
onSave={() => void saveEdit()}
disabled={isSavingEdit}
saveDisabled={isSavingEdit || !editedText.trim() || editedText.trim() === message.content.trim()}
messageId={message.id}
projectId={projectId}
/>
) : (
isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>
)}

View File

@@ -7,6 +7,8 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { addSteeringComment, refineTask } from "../api";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import type { ToastType } from "../hooks/useToast";
import { getErrorMessage } from "@fusion/core";
import { linkifyFilePaths } from "../utils/filePathLinkify";
@@ -640,6 +642,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const previousActiveRef = useRef(false);
const anchorFrameRef = useRef<number | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef, value: draft, onChange: setDraft, projectId });
const userMessages = useMemo(
() => mergeUserMessages(task.steeringComments, optimisticMessages),
@@ -1077,6 +1080,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
aria-label={t("taskChat.messageActiveAgentSession", "Message active agent session")}
rows={1}
/>
<MicButton {...dictation.micProps} disabled={sending} />
<button
type="submit"
className="btn btn-primary btn-icon task-chat-send"

View File

@@ -1,4 +1,6 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { Task, TaskComment } from "@fusion/core";
@@ -27,9 +29,32 @@ function isAIGuidanceComment(author: string): boolean {
return author === "agent" || author === "system";
}
function EditCommentComposer({ value, onChange, onCancel, onSave, disabled, projectId, t }: {
value: string;
onChange: (value: string) => void;
onCancel: () => void;
onSave: () => void;
disabled: boolean;
projectId?: string;
t: TFunction<"app">;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef, value, onChange, projectId });
return <div className="comments-edit-form">
<textarea ref={textareaRef} value={value} onChange={(event) => onChange(event.target.value)} disabled={disabled} rows={3} className="comments-textarea" />
<div className="comments-edit-actions">
<MicButton {...dictation.micProps} disabled={disabled} />
<button className="btn btn-sm" onClick={onCancel} disabled={disabled}>{t("actions.cancel", "Cancel")}</button>
<button className="btn btn-primary btn-sm" onClick={onSave} disabled={disabled || !value.trim()}>{t("actions.save", "Save")}</button>
</div>
</div>;
}
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user", projectId }: TaskCommentsProps) {
const { t } = useTranslation("app");
const [draft, setDraft] = useState("");
const draftRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef: draftRef, value: draft, onChange: setDraft, projectId });
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -142,33 +167,15 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
) : null}
</div>
{isEditing ? (
<div className="comments-edit-form">
<textarea
value={editingText}
onChange={(event) => setEditingText(event.target.value)}
rows={3}
className="comments-textarea"
/>
<div className="comments-edit-actions">
<button
className="btn btn-sm"
onClick={() => {
setEditingId(null);
setEditingText("");
}}
disabled={submitting}
>
{t("actions.cancel", "Cancel")}
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleSaveEdit(comment.id)}
disabled={submitting || !editingText.trim()}
>
{t("actions.save", "Save")}
</button>
</div>
</div>
<EditCommentComposer
value={editingText}
onChange={setEditingText}
onCancel={() => { setEditingId(null); setEditingText(""); }}
onSave={() => void handleSaveEdit(comment.id)}
disabled={submitting}
projectId={projectId}
t={t}
/>
) : (
<div className="detail-log-outcome comments-outcome-text">
{comment.text}
@@ -182,6 +189,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
<div className="comments-compose-form">
<textarea
ref={draftRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
@@ -189,7 +197,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
placeholder={placeholder}
className="comments-textarea"
/>
<div className="comments-footer-row">
<div className="comments-footer-row"><MicButton {...dictation.micProps} disabled={submitting} />
<span className={`comments-char-count${isOverLimit ? " comments-char-count--over" : ""}`}>
{draft.length} / {MAX_COMMENT_LENGTH}
</span>

View File

@@ -1,5 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflows, fetchWorkflowOptionalSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
@@ -318,6 +320,15 @@ export function TaskForm({
const depDropdownRef = useRef<HTMLDivElement>(null);
const workflowDropdownRef = useRef<HTMLDivElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
// FNXC:VoiceInput 2026-07-24-05:00: Controlled dictated updates must use the same
// description autosize routine as keyboard events after their React render commits.
const resizeDescription = useCallback(() => {
const element = descTextareaRef.current;
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`;
}, []);
const dictation = useComposerDictation({ textareaRef: descTextareaRef, value: description, onChange: onDescriptionChange, onResize: resizeDescription, projectId });
const titleInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -724,10 +735,8 @@ export function TaskForm({
// Auto-resize textarea
const handleDescriptionInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onDescriptionChange(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [onDescriptionChange]);
resizeDescription();
}, [onDescriptionChange, resizeDescription]);
const handleToggleDescriptionExpand = useCallback(() => {
setIsDescriptionExpanded((prev) => !prev);
@@ -922,6 +931,7 @@ export function TaskForm({
rows={mode === "edit" ? 8 : 5}
disabled={disabled || isRefining}
/>
<MicButton {...dictation.micProps} disabled={disabled || isRefining} />
{/* Determine if refine button will be shown — controls expand button placement */}
{(() => {
const showRefineButton = Boolean(description.trim()) && !disabled;

View File

@@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
import { attachChatStream, editChatMessage, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
@@ -309,6 +311,8 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const composerTextareaRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef: composerTextareaRef, value: draft, onChange: setDraft, projectId });
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [commandFilter, setCommandFilter] = useState("");
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
@@ -1099,6 +1103,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
activeModelTag={activeModelTag}
activeModelProvider={planningModelProvider ?? null}
activeSessionId={sessionId}
projectId={projectId}
isAwaitingQuestionAnswer={message.role === "assistant"}
onQuestionSubmit={(answerText) => void sendMessageContent(answerText)}
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
@@ -1164,6 +1169,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
)}
<div className="task-planner-chat-composer">
<textarea
ref={composerTextareaRef}
className="input task-planner-chat-input"
aria-label={t("taskDetail.plannerChat.inputLabel", "Message planner chat")}
placeholder={t("taskDetail.plannerChat.placeholder", "Ask the planner about this task… Type / for commands")}
@@ -1173,6 +1179,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
disabled={composerState === "sending"}
rows={1}
/>
<MicButton {...dictation.micProps} disabled={composerState === "sending"} />
<StandardChatActionButton
isStreaming={composerState === "sending"}
canSend={canSend}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { MicButton } from "../MicButton";
const props = { enabled: true, supported: true, state: "idle" as const, start: vi.fn(), stop: vi.fn() };
describe("MicButton", () => {
it("renders no shell until voice is enabled and confirmed available", () => {
const { rerender } = render(<MicButton {...props} enabled={false} />);
expect(document.querySelector(".btn-icon")).toBeNull();
expect(screen.queryByLabelText(/voice dictation/i)).toBeNull();
rerender(<MicButton {...props} supported={false} />);
expect(document.querySelector(".btn-icon")).toBeNull();
});
it("announces and toggles recording", async () => {
const user = userEvent.setup();
const start = vi.fn();
const stop = vi.fn();
const { rerender } = render(<MicButton {...props} start={start} stop={stop} />);
await user.click(screen.getByRole("button", { name: "Start voice dictation" }));
expect(start).toHaveBeenCalledOnce();
rerender(<MicButton {...props} state="listening" start={start} stop={stop} />);
await user.click(screen.getByRole("button", { name: "Stop voice dictation" }));
expect(stop).toHaveBeenCalledOnce();
});
});

View File

@@ -9,6 +9,11 @@ import { scopedKey } from "../../utils/projectStorage";
import { getPriorityColorVar } from "../../utils/priorityIndicator";
import { loadAllAppCss } from "../../test/cssFixture";
// FNXC:VoiceInput 2026-07-26-05:05: Keep legacy quick-entry tests focused on task creation settings; voice behavior has dedicated suites.
vi.mock("../../hooks/useComposerDictation", () => ({
useComposerDictation: () => ({ micProps: { enabled: false, supported: false, state: "idle", start: vi.fn(), stop: vi.fn() } }),
}));
const MOCK_MODELS = [
{
provider: "anthropic",

View File

@@ -23,6 +23,11 @@ vi.mock("lucide-react", () => ({
Cpu: () => null,
}));
// FNXC:VoiceInput 2026-07-26-05:05: Keep legacy form tests focused on form settings; voice behavior has dedicated hook and composer-invariant suites.
vi.mock("../../hooks/useComposerDictation", () => ({
useComposerDictation: () => ({ micProps: { enabled: false, supported: false, state: "idle", start: vi.fn(), stop: vi.fn() } }),
}));
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { computeInsertion, replaceRange } from "../insertAtCaret";
describe("dictation insertion", () => {
it("inserts at empty, start, middle, and end", () => {
expect(computeInsertion({ value: "", selectionStart: 0, selectionEnd: 0, insertText: "hello" })).toMatchObject({ nextValue: "hello", nextCaret: 5 });
expect(computeInsertion({ value: "world", selectionStart: 0, selectionEnd: 0, insertText: "hello " }).nextValue).toBe("hello world");
expect(computeInsertion({ value: "ab", selectionStart: 1, selectionEnd: 1, insertText: "X" }).nextValue).toBe("aXb");
expect(computeInsertion({ value: "a", selectionStart: 1, selectionEnd: 1, insertText: "b" }).nextValue).toBe("ab");
});
it("replaces active selections and repeated previews", () => {
const first = computeInsertion({ value: "before after", selectionStart: 7, selectionEnd: 12, insertText: "one" });
const second = replaceRange({ value: first.nextValue, anchor: first.anchor, nextText: "two words" });
const final = replaceRange({ value: second.nextValue, anchor: second.anchor, nextText: "final" });
expect(first.nextValue).toBe("before one");
expect(second.nextValue).toBe("before two words");
expect(final.nextValue).toBe("before final");
expect(final.anchor).toEqual({ start: 7, end: 12 });
});
it("preserves intervening text when its caller shifts an anchor for a prior edit", () => {
const first = computeInsertion({ value: "after", selectionStart: 0, selectionEnd: 0, insertText: "partial" });
const shifted = { start: first.anchor.start + 7, end: first.anchor.end + 7 };
expect(replaceRange({ value: "prefix partialafter", anchor: shifted, nextText: "final" }).nextValue).toBe("prefix finalafter");
});
});

View File

@@ -0,0 +1,230 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ComposeChatPanel } from "../ComposeChatPanel";
import { QuickEntryBox } from "../QuickEntryBox";
import { TaskComments } from "../TaskComments";
import { TaskForm } from "../TaskForm";
import { NewTaskModal } from "../NewTaskModal";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
import { TaskChatTab } from "../TaskChatTab";
import { PlanningModeModal, QuestionForm, SummaryView } from "../PlanningModeModal";
import { StandardChatMessageItem } from "../StandardChatSurface";
import { ChatView } from "../ChatView";
import { QuickChatFAB } from "../QuickChatFAB";
import { ToastProvider } from "../../hooks/useToast";
import { NavigationHistoryProvider } from "../../hooks/useNavigationHistory";
const mockFetchAiSession = vi.hoisted(() => vi.fn());
let voice = {
enabled: true,
supported: true,
state: "idle" as "idle" | "listening" | "transcribing" | "error",
partialText: "",
finalText: "",
error: undefined as string | undefined,
start: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
stop: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
};
const voiceListeners = new Set<() => void>();
vi.mock("../../hooks/useVoiceDictation", async () => {
const React = await import("react");
return {
useVoiceDictation: () => React.useSyncExternalStore(
(listener) => { voiceListeners.add(listener); return () => voiceListeners.delete(listener); },
() => voice,
() => voice,
),
};
});
const chatSession = { id: "voice-session", agentId: "agent-1", status: "active", title: "Voice", createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z" };
const chatState = {
sessions: [chatSession], activeSession: chatSession, sessionsLoading: false, messages: [], messagesLoading: false,
isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [], selectSession: vi.fn(),
createSession: vi.fn(), archiveSession: vi.fn(), renameSession: vi.fn(), pinSession: vi.fn(), pinnedCount: 0,
setSessionModel: vi.fn(), setSessionThinkingLevel: vi.fn(), deleteSession: vi.fn(), tags: [], selectedTagId: null,
setSelectedTagId: vi.fn(), createTag: vi.fn(), renameTag: vi.fn(), deleteTag: vi.fn(), setSessionTags: vi.fn(),
sendMessage: vi.fn(), editMessageAndResend: vi.fn(), stopStreaming: vi.fn(), pendingMessages: [], clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), filteredSessions: [chatSession],
agentsMap: new Map(),
};
let activeRoom: any = null;
vi.mock("../../hooks/useChat", async (importOriginal) => ({ ...(await importOriginal<typeof import("../../hooks/useChat")>()), useChat: () => chatState }));
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: () => ({ rooms: [], roomsLoading: false, roomsError: null, activeRoom, activeRoomMembers: [], messages: [], messagesLoading: false, selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), clearRoom: vi.fn(), refreshRooms: vi.fn() }) }));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({ ...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()), useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
vi.mock("../../api", async (importOriginal) => ({ ...(await importOriginal<typeof import("../../api")>()), fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchSettings: vi.fn().mockResolvedValue({}), fetchAgents: vi.fn().mockResolvedValue([]), fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), fetchTasks: vi.fn().mockResolvedValue([]), searchFiles: vi.fn().mockResolvedValue({ files: [] }) }));
function setVoice(next: Partial<typeof voice>) {
voice = { ...voice, ...next };
voiceListeners.forEach((listener) => listener());
}
function taskWithComment() {
return {
id: "FN-8573", description: "Voice", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: "2026-07-24T00:00:00.000Z", updatedAt: "2026-07-24T00:00:00.000Z",
comments: [{ id: "comment-1", text: "Original", author: "user", createdAt: "2026-07-24T00:00:00.000Z" }],
} as any;
}
const formProps = {
mode: "create" as const, description: "", onDescriptionChange: vi.fn(), dependencies: [], onDependenciesChange: vi.fn(),
executorModel: "", onExecutorModelChange: vi.fn(), validatorModel: "", onValidatorModelChange: vi.fn(),
presetMode: "default" as const, onPresetModeChange: vi.fn(), selectedPresetId: "", onSelectedPresetIdChange: vi.fn(),
selectedWorkflowId: undefined, onWorkflowIdChange: vi.fn(), pendingImages: [], onImagesChange: vi.fn(), tasks: [],
addToast: vi.fn(), isActive: true, reviewLevel: undefined, onReviewLevelChange: vi.fn(),
};
function ControlledTaskForm() {
const [description, setDescription] = useState("");
return <TaskForm {...formProps} description={description} onDescriptionChange={setDescription} />;
}
/** Mirrors App's FAB → full ChatView handoff so this test exercises the reachable shared composer. */
function QuickChatVoicePath() {
const [open, setOpen] = useState(false);
return <>
<QuickChatFAB open={open} onOpenChange={setOpen} />
{open && <ChatView projectId="project-1" addToast={vi.fn()} floating />}
</>;
}
function ControlledSummaryView() {
const [summary, setSummary] = useState({ title: "Voice", description: "before-after", priority: "normal", suggestedDependencies: [] } as any);
return <SummaryView projectId="project-1" summary={summary} historyEntries={[]} onSummaryChange={setSummary} tasks={[]} branchMode="project-default" branchName="" baseBranch="main" onBranchModeChange={vi.fn()} onBranchNameChange={vi.fn()} onBaseBranchChange={vi.fn()} onCreateTask={vi.fn()} onBreakIntoTasks={vi.fn()} isCreatingTask={false} isStartingBreakdown={false} isRefiningSummary={false} />;
}
/** Reaches the modal's primary refinement composer rather than a shallow surrogate. */
async function renderRefinementComposer() {
mockFetchAiSession.mockResolvedValue({
id: "voice-refinement", title: "Voice refinement", projectId: "project-1", updatedAt: "2026-07-24T00:00:00.000Z", archived: false,
status: "awaiting_input", currentQuestion: JSON.stringify({ id: "voice-question", type: "text", question: "What should change?" }),
result: JSON.stringify({ title: "Voice", description: "before-after", priority: "normal", suggestedDependencies: [] }), inputPayload: "{}", conversationHistory: "[]", thinkingOutput: "",
});
const view = render(<ToastProvider><NavigationHistoryProvider value={{ pushNav: vi.fn(), removeNav: vi.fn() } as any}><PlanningModeModal isOpen presentation="modal" onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} projectId="project-1" resumeSessionId="voice-refinement" /></NavigationHistoryProvider></ToastProvider>);
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
return view;
}
const primarySurfaceRenders = [
{ name: "ChatView primary composer", render: () => { activeRoom = null; return render(<ChatView projectId="project-1" addToast={vi.fn()} />); } },
{ name: "ChatView secondary room composer", render: () => { activeRoom = { id: "room-1", name: "Room" }; return render(<ChatView projectId="project-1" addToast={vi.fn()} />); } },
{ name: "StandardChatSurface correction composer", render: () => { const result = render(<StandardChatMessageItem message={{ id: "message-1", role: "user", content: "Populated", createdAt: "2026-07-24T00:00:00.000Z" } as any} forcePlain={false} agentName="Agent" hideAssistantIdentity={false} showAssistantModelTag={false} activeSessionId="session-1" canEdit onEditMessage={vi.fn()} />); fireEvent.click(screen.getByRole("button", { name: /edit/i })); return result; } },
{ name: "QuickChatFAB-opened shared ChatView composer", render: () => { const result = render(<QuickChatVoicePath />); fireEvent.click(screen.getByTestId("quick-chat-fab")); return result; } },
{ name: "ComposeChatPanel request composer", render: () => render(<ComposeChatPanel embeds={[]} draftBody="" onUseDraft={vi.fn()} onClose={vi.fn()} />) },
{ name: "TaskPlannerChatTab composer", render: () => render(<ToastProvider><NavigationHistoryProvider value={{ pushNav: vi.fn(), removeNav: vi.fn() } as any}><TaskPlannerChatTab task={taskWithComment()} active planningModel={{ provider: "mock", modelId: "mock" }} addToast={vi.fn()} /></NavigationHistoryProvider></ToastProvider>) },
{ name: "TaskChatTab composer", render: () => render(<TaskChatTab task={taskWithComment()} active projectId="project-1" addToast={vi.fn()} />) },
{ name: "QuickEntryBox composer", render: () => render(<QuickEntryBox addToast={vi.fn()} tasks={[]} defaultExpanded />) },
{ name: "TaskForm description composer", render: () => render(<ControlledTaskForm />) },
{ name: "NewTaskModal inherited TaskForm composer", render: () => render(<NewTaskModal isOpen projectId="project-1" tasks={[]} onClose={vi.fn()} onCreateTask={vi.fn()} addToast={vi.fn()} />) },
{ name: "TaskComments new composer", render: () => render(<TaskComments task={taskWithComment()} addToast={vi.fn()} />) },
{ name: "TaskComments edit composer", render: () => { const result = render(<TaskComments task={taskWithComment()} addToast={vi.fn()} />); fireEvent.click(screen.getByRole("button", { name: "Edit" })); return result; } },
{ name: "PlanningModeModal initial-plan composer", render: () => render(<ToastProvider><NavigationHistoryProvider value={{ pushNav: vi.fn(), removeNav: vi.fn() } as any}><PlanningModeModal isOpen presentation="modal" onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} /></NavigationHistoryProvider></ToastProvider>) },
{ name: "PlanningModeModal free-text answer composer", render: () => render(<QuestionForm question={{ id: "voice-question", type: "text", question: "Describe it" } as any} onSubmit={vi.fn()} />) },
{ name: "PlanningModeModal summary description composer", render: () => {
const view = render(<ControlledSummaryView />);
fireEvent.click(screen.getByRole("button", { name: "Show raw text" }));
return view;
} },
] as const;
async function exerciseRealComposer(renderSurface: () => ReturnType<typeof render>) {
const view = renderSurface();
await act(async () => undefined);
const root = view.container.querySelector("textarea") ? view.container : document;
const textarea = root.querySelector("textarea") as HTMLTextAreaElement | null;
const mic = root.querySelector("button[aria-label='Start voice dictation']") as HTMLButtonElement | null;
expect(textarea).not.toBeNull();
expect(mic).not.toBeNull();
fireEvent.change(textarea!, { target: { value: "before-after" } });
textarea!.setSelectionRange("before".length, "before".length);
fireEvent.click(mic!);
act(() => setVoice({ state: "listening", partialText: "partial", finalText: "" }));
expect(textarea).toHaveValue("beforepartial-after");
act(() => setVoice({ partialText: "partial-next" }));
expect(textarea).toHaveValue("beforepartial-next-after");
act(() => setVoice({ state: "idle", partialText: "", finalText: "final" }));
expect(textarea).toHaveValue("beforefinal-after");
view.unmount();
}
describe("voice dictation composer inventory", () => {
beforeEach(async () => {
vi.clearAllMocks(); activeRoom = null;
await act(async () => { setVoice({ enabled: true, supported: true, state: "idle", partialText: "", finalText: "", error: undefined }); });
});
afterEach(cleanup);
it("opens the reachable shared ChatView composer from QuickChatFAB", () => {
render(<QuickChatVoicePath />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
expect(screen.getByRole("button", { name: "Start voice dictation" })).toBeInTheDocument();
});
it("opens and dictates into the reachable PlanningModeModal refinement editor", async () => {
const view = await renderRefinementComposer();
await exerciseRealComposer(() => view);
});
it.each(primarySurfaceRenders)("renders exactly a shared mic on $name and removes its subscription on unmount", ({ render: renderSurface }) => {
const listenersBefore = voiceListeners.size;
const view = renderSurface();
expect(screen.getAllByRole("button", { name: "Start voice dictation" }).length).toBeGreaterThan(0);
view.unmount();
expect(voiceListeners.size).toBe(listenersBefore);
});
it.each([
["voice mode is disabled", { enabled: false }],
["runtime status is pending", { supported: false }],
["runtime status request failed", { supported: false }],
["runtime is unavailable", { supported: false }],
] as const)("renders no shell or dangling label on every real composer when %s", async (_caseName, gate) => {
setVoice(gate);
for (const surface of primarySurfaceRenders) {
const view = surface.render();
expect(screen.queryByRole("button", { name: /voice dictation/i })).not.toBeInTheDocument();
expect(document.querySelectorAll(".mic-button")).toHaveLength(0);
expect(document.querySelectorAll("[aria-label*='voice dictation' i]")).toHaveLength(0);
view.unmount();
}
// The refinement editor is reachable only after resuming and opening a modal session.
const refinementView = await renderRefinementComposer();
expect(screen.queryByRole("button", { name: /voice dictation/i })).not.toBeInTheDocument();
expect(document.querySelectorAll(".mic-button")).toHaveLength(0);
expect(document.querySelectorAll("[aria-label*='voice dictation' i]")).toHaveLength(0);
refinementView.unmount();
});
it.each(primarySurfaceRenders.filter((surface) => surface.name !== "QuickEntryBox composer"))("drives anchored partial → final replacement through real $name", async ({ render: renderSurface }) => {
await exerciseRealComposer(renderSurface);
});
it("drives anchored partial → final replacement through the real QuickEntryBox composer", async () => {
const view = render(<QuickEntryBox onCreate={async () => undefined} addToast={vi.fn()} tasks={[]} defaultExpanded />);
await act(async () => undefined);
const textarea = view.container.querySelector("textarea")!;
const mic = view.container.querySelector("button[aria-label='Start voice dictation']")!;
fireEvent.change(textarea, { target: { value: "before-after" } });
textarea.setSelectionRange("before".length, "before".length);
fireEvent.click(mic);
act(() => setVoice({ state: "listening", partialText: "partial", finalText: "" }));
expect(textarea).toHaveValue("beforepartial-after");
act(() => setVoice({ partialText: "partial-next" }));
expect(textarea).toHaveValue("beforepartial-next-after");
act(() => setVoice({ state: "idle", partialText: "", finalText: "final" }));
expect(textarea).toHaveValue("beforefinal-after");
});
it("leaves no mobile composer shell when voice mode is disabled", () => {
setVoice({ enabled: false });
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }) as any;
render(<ComposeChatPanel embeds={[]} draftBody="" onUseDraft={vi.fn()} onClose={vi.fn()} />);
expect(document.querySelectorAll(".mic-button")).toHaveLength(0);
expect(document.querySelectorAll("[aria-label*='voice dictation' i]")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,20 @@
/**
* FNXC:VoiceInput 2026-07-24-03:00:
* Dictation performs real-time partial-to-final transcription at the caret. The returned anchor
* keeps successive previews replacing in place so surrounding controlled-composer text survives.
*/
export type DictationAnchor = { start: number; end: number };
export type Insertion = { nextValue: string; nextCaret: number; anchor: DictationAnchor };
export function computeInsertion({ value, selectionStart, selectionEnd, insertText }: { value: string; selectionStart: number; selectionEnd: number; insertText: string }): Insertion {
const start = Math.max(0, Math.min(selectionStart, value.length));
const end = Math.max(start, Math.min(selectionEnd, value.length));
const nextValue = `${value.slice(0, start)}${insertText}${value.slice(end)}`;
const nextCaret = start + insertText.length;
return { nextValue, nextCaret, anchor: { start, end: nextCaret } };
}
/** Replaces the previous dictated range; callers may shift the anchor when outside edits occur. */
export function replaceRange({ value, anchor, nextText }: { value: string; anchor: DictationAnchor; nextText: string }): Insertion {
return computeInsertion({ value, selectionStart: anchor.start, selectionEnd: anchor.end, insertText: nextText });
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useRef, useState } from "react";
const voice = { enabled: true, supported: true, state: "idle" as const, partialText: "", finalText: "", error: undefined, start: vi.fn(), stop: vi.fn() };
vi.mock("../useVoiceDictation", () => ({ useVoiceDictation: () => voice }));
import { useComposerDictation } from "../useComposerDictation";
function Composer({ label, onResize }: { label: string; onResize?: () => void }) {
const [value, setValue] = useState("before after");
const ref = useRef<HTMLTextAreaElement>(null);
const { micProps } = useComposerDictation({ textareaRef: ref, value, onChange: setValue, onResize });
return <><textarea aria-label={label} ref={ref} value={value} onChange={(event) => setValue(event.target.value)} /><button onClick={() => void micProps.start()}>start {label}</button><output>{value}</output></>;
}
describe("useComposerDictation", () => {
it("captures each controlled composer's own caret before dictation", async () => {
render(<><Composer label="first" /><Composer label="second" /></>);
const first = screen.getByLabelText("first") as HTMLTextAreaElement;
const second = screen.getByLabelText("second") as HTMLTextAreaElement;
first.setSelectionRange(7, 7);
second.setSelectionRange(0, 6);
await act(async () => { await screen.getByRole("button", { name: "start first" }).click(); });
expect(voice.start).toHaveBeenCalled();
expect(first.selectionStart).toBe(7);
expect(second.selectionStart).toBe(0);
});
it("reanchors when the user replaces the original selection before the first partial", async () => {
const view = render(<Composer label="pre-partial edit" />);
const textarea = screen.getByLabelText("pre-partial edit") as HTMLTextAreaElement;
textarea.setSelectionRange(0, 6);
await act(async () => { await screen.getByRole("button", { name: "start pre-partial edit" }).click(); });
fireEvent.change(textarea, { target: { value: "typed after" } });
textarea.setSelectionRange(5, 5);
voice.state = "listening";
voice.partialText = " speech";
await act(async () => { view.rerender(<Composer label="pre-partial edit" />); });
expect((screen.getByLabelText("pre-partial edit") as HTMLTextAreaElement).value).toBe("typed speech after");
voice.state = "idle";
voice.partialText = "";
});
it("runs the composer's resize callback after applying dictated text", async () => {
const onResize = vi.fn();
const view = render(<Composer label="resizable" onResize={onResize} />);
const textarea = screen.getByLabelText("resizable") as HTMLTextAreaElement;
textarea.setSelectionRange(6, 6);
await act(async () => { await screen.getByRole("button", { name: "start resizable" }).click(); });
voice.state = "listening";
voice.partialText = " dictated";
await act(async () => { view.rerender(<Composer label="resizable" onResize={onResize} />); });
expect(onResize).toHaveBeenCalledTimes(1);
expect((screen.getByLabelText("resizable") as HTMLTextAreaElement).value).toBe("before dictated after");
voice.state = "idle";
voice.partialText = "";
});
});

View File

@@ -0,0 +1,158 @@
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";
function Harness() {
const voice = useVoiceDictation();
return <>
<output data-testid="voice">{JSON.stringify({ enabled: voice.enabled, supported: voice.supported, partialText: voice.partialText, finalText: voice.finalText })}</output>
<button onClick={() => void voice.start()}>start</button>
<button onClick={() => void voice.stop()}>stop</button>
</>;
}
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" } }));
if (url === "/api/voice/session") return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
if (url === "/api/voice/transcribe") return new Response(JSON.stringify({ text: "final transcript", final: true }));
if (url === "/api/voice/session/session-1" && init?.method === "DELETE") return new Response("{}");
throw new Error(`Unexpected request ${url}`);
});
}
function installAudioCapture() {
const tracks = [{ stop: vi.fn() }];
const port = { onmessage: undefined as ((event: MessageEvent<ArrayBuffer>) => void) | undefined };
class Context {
audioWorklet = { addModule: vi.fn().mockResolvedValue(undefined) };
createMediaStreamSource = vi.fn(() => ({ connect: vi.fn(), disconnect: vi.fn() }));
close = vi.fn().mockResolvedValue(undefined);
}
vi.stubGlobal("AudioWorkletNode", class { port = port; disconnect = vi.fn(); });
Object.defineProperty(window, "AudioContext", { configurable: true, value: Context });
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => tracks }) } });
return { tracks, port };
}
describe("useVoiceDictation", () => {
beforeEach(() => {
vi.mocked(fetchSettings).mockReset();
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"));
render(<Harness />);
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"enabled":true'));
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);
render(<Harness />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(fetch).not.toHaveBeenCalled();
});
it("fails closed when AudioWorkletNode is unavailable", async () => {
installAudioCapture();
vi.stubGlobal("AudioWorkletNode", undefined);
availableResponses();
render(<Harness />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"enabled":true'));
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
});
it("serializes buffered worklet frames and sends a bounded finalization", async () => {
const { tracks, port } = installAudioCapture();
availableResponses();
render(<Harness />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
await act(async () => {
// A full 200ms batch starts one ordered request; the remaining frame flushes on stop.
port.onmessage?.({ data: new ArrayBuffer(6_400) } as MessageEvent<ArrayBuffer>);
port.onmessage?.({ data: new ArrayBuffer(256) } as MessageEvent<ArrayBuffer>);
});
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(([url]) => url === "/api/voice/transcribe").length).toBe(1));
fireEvent.click(screen.getByText("stop"));
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(([url]) => url === "/api/voice/transcribe").length).toBe(2));
const requests = vi.mocked(fetch).mock.calls.filter(([url]) => url === "/api/voice/transcribe");
expect(JSON.parse(String(requests.at(-1)?.[1]?.body))).toMatchObject({ final: true, sequence: 1 });
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"finalText":"final transcript"'));
expect(tracks[0].stop).toHaveBeenCalledOnce();
});
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) => {
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 });
if (url === "/api/voice/transcribe" && !JSON.parse(String(init?.body)).final) return await new Promise<Response>(() => undefined);
return new Response(JSON.stringify({ text: "", final: true }));
});
render(<Harness />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
await act(async () => { port.onmessage?.({ data: new ArrayBuffer(6_400) } as MessageEvent<ArrayBuffer>); });
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url]) => url === "/api/voice/transcribe")).toBe(true));
fireEvent.click(screen.getByText("stop"));
expect(tracks[0].stop).toHaveBeenCalledOnce();
// A request already in flight may have reached the server, so stop releases the track but
// waits for that original sequence instead of replaying PCM as a second request.
await act(async () => undefined);
expect(vi.mocked(fetch).mock.calls.filter(([url]) => url === "/api/voice/transcribe")).toHaveLength(1);
});
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) => {
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 });
if (url === "/api/voice/transcribe") {
stalledSignal = init?.signal as AbortSignal;
return await new Promise<Response>(() => undefined);
}
if (url === "/api/voice/session/session-1" && init?.method === "DELETE") return new Response("{}");
throw new Error(`Unexpected request ${url}`);
});
render(<Harness />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
await act(async () => { port.onmessage?.({ data: new ArrayBuffer(6_400) } as MessageEvent<ArrayBuffer>); });
await waitFor(() => expect(stalledSignal).toBeDefined());
vi.useFakeTimers();
fireEvent.click(screen.getByText("stop"));
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(stalledSignal?.aborted).toBe(true);
expect(vi.mocked(fetch).mock.calls.some(([url, init]) => url === "/api/voice/session/session-1" && init?.method === "DELETE")).toBe(true);
});
it("prevents a rapid double-start from creating multiple captures", async () => {
const { port } = installAudioCapture();
availableResponses();
render(<Harness />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
expect(vi.mocked(fetch).mock.calls.filter(([url]) => url === "/api/voice/session").length).toBe(1);
});
});

View File

@@ -0,0 +1,112 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import { computeInsertion, replaceRange, type DictationAnchor } from "../components/insertAtCaret";
import { useVoiceDictation } from "./useVoiceDictation";
/**
* FNXC:VoiceInput 2026-07-24-03:15:
* Each controlled composer owns one adapter instance. Its private anchor replaces partial text
* in place and restores selection after React renders, preventing one composer from clobbering another.
*/
export function useComposerDictation({ textareaRef, value, onChange, onResize, projectId }: { textareaRef: React.RefObject<HTMLTextAreaElement | null>; value: string; onChange: (nextValue: string) => void; onResize?: () => void; projectId?: string }) {
const voice = useVoiceDictation(projectId);
const resizeRef = useRef(onResize); resizeRef.current = onResize;
const pendingResizeRef = useRef(false);
const valueRef = useRef(value); valueRef.current = value;
const anchorRef = useRef<DictationAnchor | undefined>(undefined);
const appliedRef = useRef<string | undefined>(undefined);
const pendingCaretRef = useRef<number | undefined>(undefined);
/*
* FNXC:VoiceInput 2026-07-25-12:20:
* A mounted composer must not consume another composer's dictation events. Only its own mic
* start arms partial-to-final replacement, preserving independent ChatView composer anchors.
*/
const sessionActiveRef = useRef(false);
const lastPartialRef = useRef("");
const reconcileExternalEdit = useCallback((current: string, node: HTMLTextAreaElement) => {
const applied = appliedRef.current;
const anchor = anchorRef.current;
if (!applied || !anchor || applied === current) return;
let prefix = 0;
while (prefix < applied.length && prefix < current.length && applied[prefix] === current[prefix]) prefix += 1;
let suffix = 0;
while (suffix < applied.length - prefix && suffix < current.length - prefix && applied[applied.length - 1 - suffix] === current[current.length - 1 - suffix]) suffix += 1;
const previousEditEnd = applied.length - suffix;
const nextEditEnd = current.length - suffix;
if (previousEditEnd <= anchor.start) {
const delta = nextEditEnd - previousEditEnd;
anchorRef.current = { start: anchor.start + delta, end: anchor.end + delta };
} else if (prefix < anchor.end) {
// FNXC:VoiceInput 2026-07-24-04:10:
// An edit touching the preview commits that preview and reanchors at the live selection;
// later speech must not overwrite text typed by the operator during dictation.
anchorRef.current = { start: node.selectionStart, end: node.selectionEnd };
}
appliedRef.current = current;
}, []);
const apply = useCallback((text: string, initial = false) => {
const node = textareaRef.current; if (!node) return;
const current = valueRef.current;
reconcileExternalEdit(current, node);
const result = initial || !anchorRef.current
? computeInsertion({ value: current, selectionStart: node.selectionStart, selectionEnd: node.selectionEnd, insertText: text })
: replaceRange({ value: current, anchor: anchorRef.current, nextText: text });
anchorRef.current = result.anchor; appliedRef.current = result.nextValue; pendingCaretRef.current = result.nextCaret; pendingResizeRef.current = true; lastPartialRef.current = text;
onChange(result.nextValue);
}, [onChange, reconcileExternalEdit, textareaRef]);
/*
* FNXC:VoiceInput 2026-07-24-05:00:
* Dictated controlled-value updates run the caller's existing resize routine after React commits.
* This preserves autosize parity with keyboard input without mutating textarea.value directly.
*/
useLayoutEffect(() => {
const caret = pendingCaretRef.current;
if (caret !== undefined && textareaRef.current) {
textareaRef.current.setSelectionRange(caret, caret);
pendingCaretRef.current = undefined;
}
if (pendingResizeRef.current) {
pendingResizeRef.current = false;
resizeRef.current?.();
}
}, [value, textareaRef]);
const priorPartial = useRef(voice.partialText);
useLayoutEffect(() => {
if (sessionActiveRef.current && voice.state === "listening" && voice.partialText && voice.partialText !== priorPartial.current) apply(voice.partialText, !anchorRef.current);
priorPartial.current = voice.partialText;
}, [apply, voice.partialText, voice.state]);
const priorFinal = useRef(voice.finalText);
useLayoutEffect(() => {
if (sessionActiveRef.current && voice.finalText && voice.finalText !== priorFinal.current) {
apply(voice.finalText, !anchorRef.current);
anchorRef.current = undefined;
sessionActiveRef.current = false;
}
priorFinal.current = voice.finalText;
}, [apply, voice.finalText]);
const start = useCallback(async () => {
const node = textareaRef.current;
if (node) {
anchorRef.current = { start: node.selectionStart, end: node.selectionEnd };
// FNXC:VoiceInput 2026-07-24-06:20: Capture a baseline before permission/session startup
// so a user edit before the first partial is reconciled instead of stale-selection overwrite.
appliedRef.current = valueRef.current;
}
lastPartialRef.current = "";
sessionActiveRef.current = true;
await voice.start();
}, [textareaRef, voice]);
useLayoutEffect(() => {
if (voice.state === "error") {
anchorRef.current = undefined;
sessionActiveRef.current = false;
}
}, [voice.state]);
useLayoutEffect(() => () => { anchorRef.current = undefined; sessionActiveRef.current = false; }, []);
const stop = useCallback(async () => {
// Keep the preview anchor alive while stop flushes the final transcript.
await voice.stop();
}, [voice]);
return { micProps: { enabled: voice.enabled, supported: voice.supported, state: voice.state, error: voice.error, start, stop } };
}

View File

@@ -0,0 +1,277 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSettings } from "../api";
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);
}
/**
* FNXC:VoiceInput 2026-07-24-04:10:
* Dictation is opt-in and fail-closed: the microphone remains unavailable until settings and the
* installed runtime explicitly confirm availability. Worklet frames are FIFO-batched before
* serialized submission, and an in-flight guard prevents double-clicks from owning two streams.
* 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 [state, setState] = useState<VoiceDictationState>("idle");
const [partialText, setPartialText] = useState("");
const [finalText, setFinalText] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const streamRef = useRef<MediaStream | undefined>(undefined);
const audioContextRef = useRef<AudioContext | undefined>(undefined);
const workletRef = useRef<AudioWorkletNode | undefined>(undefined);
const sourceRef = useRef<MediaStreamAudioSourceNode | undefined>(undefined);
const workletUrlRef = useRef<string | undefined>(undefined);
const sessionRef = useRef<string | undefined>(undefined);
// FNXC:VoiceInput 2026-07-25-20:30: Sequence numbers belong to backend sessions,
// not the hook instance. A new capture may start while an old stopped session finalizes.
const sequenceRef = useRef(new Map<string, number>());
const generationRef = useRef(0);
const acceptingBuffersRef = useRef(false);
const queuedBuffersRef = useRef<Blob[]>([]);
const queuedBytesRef = useRef(0);
// FNXC:VoiceInput 2026-07-25-18:30: A batch leaves the FIFO queue before its HTTP
// request settles. Stop must serialize finalization after that original request rather than
// replaying unacknowledged audio as a new sequence, because the server may already process it.
const inFlightBufferRef = useRef<Blob | undefined>(undefined);
const flushingRef = useRef<Promise<void> | undefined>(undefined);
const stoppingRef = useRef(false);
// FNXC:VoiceInput 2026-07-24-08:30: Stop must release tracks immediately even when a
// transcription request stalls, so every in-flight chunk request remains abortable.
// FNXC:VoiceInput 2026-07-25-19:10: Stop owns teardown of its captured session only.
// Session-scoped controllers let a replacement capture begin without an old timeout aborting it.
const transcriptionControllersRef = useRef(new Map<string, Set<AbortController>>());
const startInProgressRef = useRef(false);
// 200ms of 16kHz mono s16le PCM keeps requests useful without losing individual worklet frames.
const chunkBytes = 6_400;
const releaseCapture = useCallback(() => {
acceptingBuffersRef.current = false;
startInProgressRef.current = false;
workletRef.current?.disconnect(); workletRef.current = undefined;
sourceRef.current?.disconnect(); sourceRef.current = undefined;
void audioContextRef.current?.close(); audioContextRef.current = undefined;
if (workletUrlRef.current) URL.revokeObjectURL(workletUrlRef.current); workletUrlRef.current = undefined;
streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = undefined;
}, []);
const release = useCallback((preserveSession = false) => {
generationRef.current += 1;
transcriptionControllersRef.current.forEach((controllers) => controllers.forEach((controller) => controller.abort()));
transcriptionControllersRef.current.clear();
releaseCapture();
queuedBuffersRef.current = [];
queuedBytesRef.current = 0;
inFlightBufferRef.current = undefined;
// A stalled pre-stop batch must never retain the next session's flush lock.
flushingRef.current = undefined;
stoppingRef.current = false;
const id = sessionRef.current;
if (!preserveSession) {
sessionRef.current = undefined;
if (id) {
sequenceRef.current.delete(id);
void fetch(`/api/voice/session/${encodeURIComponent(id)}`, { method: "DELETE" }).catch(() => undefined);
}
}
}, [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]);
const sendChunk = useCallback(async (blob: Blob, final: boolean, sessionId: string, generation: number) => {
if (sessionRef.current !== sessionId || generationRef.current !== generation) return;
setState("transcribing");
const controller = new AbortController();
const controllers = transcriptionControllersRef.current.get(sessionId) ?? new Set<AbortController>();
controllers.add(controller);
transcriptionControllersRef.current.set(sessionId, controllers);
let response: Response;
try {
const sequence = sequenceRef.current.get(sessionId) ?? 0;
sequenceRef.current.set(sessionId, sequence + 1);
response = await fetch("/api/voice/transcribe", { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await blob.arrayBuffer()), sequence, final, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
} finally {
controllers.delete(controller);
if (controllers.size === 0) transcriptionControllersRef.current.delete(sessionId);
}
if (!response!.ok) throw new Error("Voice transcription failed");
const result = await response!.json() as { partial?: string; text?: string; final?: boolean };
// A request may settle after a stop/unmount. It must never revive that session's text.
if (sessionRef.current !== sessionId || generationRef.current !== generation) return;
if (result.final) { setFinalText(result.text ?? ""); setPartialText(""); } else setPartialText(result.partial ?? "");
setState(final ? "idle" : "listening");
}, []);
const flushBuffers = useCallback((sessionId: string, generation: number, flushPartial = false) => {
if (flushingRef.current) return flushingRef.current;
const flush = Promise.resolve().then(async () => {
try {
while (sessionRef.current === sessionId && generationRef.current === generation) {
if (!queuedBytesRef.current || (!flushPartial && queuedBytesRef.current < chunkBytes)) break;
const buffers: Blob[] = [];
let bytes = 0;
while (queuedBuffersRef.current.length && (flushPartial || bytes < chunkBytes)) {
const buffer = queuedBuffersRef.current.shift()!;
buffers.push(buffer);
bytes += buffer.size;
queuedBytesRef.current -= buffer.size;
}
const batch = new Blob(buffers);
inFlightBufferRef.current = batch;
try {
await sendChunk(batch, false, sessionId, generation);
} finally {
if (inFlightBufferRef.current === batch) inFlightBufferRef.current = undefined;
}
}
} catch (reason) {
if (!stoppingRef.current && sessionRef.current === sessionId && generationRef.current === generation) fail(reason instanceof Error ? reason.message : "Voice transcription failed");
} finally {
// FNXC:VoiceInput 2026-07-25-04:10: A stale request may settle after a new capture
// starts. Clear only its own lock so it cannot erase the new session's FIFO flush.
if (flushingRef.current === flush) flushingRef.current = undefined;
if (acceptingBuffersRef.current && queuedBytesRef.current >= chunkBytes && sessionRef.current === sessionId && generationRef.current === generation) void flushBuffers(sessionId, generation);
}
});
flushingRef.current = flush;
return flush;
}, [chunkBytes, fail, sendChunk]);
const start = useCallback(async () => {
// React state updates are asynchronous: this ref closes the double-click capture race.
if (!enabled || !supported || startInProgressRef.current) return;
startInProgressRef.current = true;
const generation = generationRef.current + 1;
generationRef.current = generation;
setError(undefined); setPartialText(""); setFinalText("");
try {
const session = await fetch("/api/voice/session", { method: "POST" });
if (!session.ok) throw new Error("Voice session unavailable");
const sessionId = (await session.json() as { sessionId: string }).sessionId;
if (!startInProgressRef.current || generationRef.current !== generation) {
void fetch(`/api/voice/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch(() => undefined);
return;
}
sessionRef.current = sessionId;
sequenceRef.current.set(sessionId, 0);
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000 } });
if (!startInProgressRef.current || generationRef.current !== generation) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
const Context = window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Context) throw new Error("Audio capture is unavailable");
const context = new Context({ sampleRate: 16000 }); audioContextRef.current = context;
if (!context.audioWorklet) throw new Error("Audio worklet capture is unavailable");
const processor = `class FusionVoiceProcessor extends AudioWorkletProcessor { process(inputs) { const input = inputs[0]?.[0]; if (input) { const pcm = new Int16Array(input.length); for (let i = 0; i < input.length; i++) pcm[i] = Math.max(-1, Math.min(1, input[i])) * 32767; this.port.postMessage(pcm.buffer, [pcm.buffer]); } return true; } } registerProcessor("fusion-voice-processor", FusionVoiceProcessor);`;
const url = URL.createObjectURL(new Blob([processor], { type: "text/javascript" })); workletUrlRef.current = url;
await context.audioWorklet.addModule(url);
if (!startInProgressRef.current || generationRef.current !== generation) return;
const source = context.createMediaStreamSource(stream); sourceRef.current = source;
const worklet = new AudioWorkletNode(context, "fusion-voice-processor"); workletRef.current = worklet;
acceptingBuffersRef.current = true;
worklet.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
if (!acceptingBuffersRef.current || sessionRef.current !== sessionId || generationRef.current !== generation) return;
// Preserve every ~8ms worklet frame in FIFO order; never overwrite audio during HTTP I/O.
const buffer = new Blob([event.data]);
queuedBuffersRef.current.push(buffer);
queuedBytesRef.current += buffer.size;
void flushBuffers(sessionId, generation);
};
source.connect(worklet); setState("listening");
} catch (reason) {
if (generationRef.current === generation) fail(reason instanceof Error ? reason.message : "Microphone permission was denied");
}
}, [enabled, fail, flushBuffers, supported]);
const stop = useCallback(() => {
const sessionId = sessionRef.current;
const generation = generationRef.current;
if (!sessionId || stoppingRef.current) { releaseCapture(); setState("idle"); return; }
stoppingRef.current = true;
// Release the browser capture synchronously, but never abort/replay the batch already sent.
// Abort is not delivery cancellation: finalization waits for that original sequence so the
// backend cannot receive the same PCM twice under distinct sequence numbers.
releaseCapture();
const trailingBuffers = queuedBuffersRef.current;
queuedBuffersRef.current = [];
queuedBytesRef.current = 0;
const flush = flushingRef.current;
setState("idle");
void (async () => {
try {
// FNXC:VoiceInput 2026-07-25-19:10: A server request can ignore abort signals forever.
// Bound the old FIFO wait, abort only that session's request, then delete its backend
// session so a later start cannot strand ownership or replay its original PCM sequence.
let flushTimedOut = false;
let flushTimeout: number | undefined;
if (flush) {
await Promise.race([
flush,
new Promise<void>((resolve) => { flushTimeout = window.setTimeout(() => { flushTimedOut = true; resolve(); }, 5_000); }),
]);
if (flushTimeout !== undefined) window.clearTimeout(flushTimeout);
}
if (flushTimedOut) transcriptionControllersRef.current.get(sessionId)?.forEach((controller) => controller.abort());
if (sessionRef.current !== sessionId || generationRef.current !== generation || flushTimedOut) return;
const trailing = new Blob(trailingBuffers.length ? trailingBuffers : [new Int16Array(1)]);
const controller = new AbortController();
const controllers = transcriptionControllersRef.current.get(sessionId) ?? new Set<AbortController>();
controllers.add(controller);
transcriptionControllersRef.current.set(sessionId, controllers);
const timeout = window.setTimeout(() => controller.abort(), 5_000);
try {
const sequence = sequenceRef.current.get(sessionId) ?? 0;
sequenceRef.current.set(sessionId, sequence + 1);
const response = await fetch("/api/voice/transcribe", { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await trailing.arrayBuffer()), sequence, final: true, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
if (!response.ok) throw new Error("Voice transcription finalization failed");
const result = await response.json() as { text?: string; partial?: string; final?: boolean };
if (generationRef.current === generation && result.final) {
setFinalText(result.text ?? result.partial ?? "");
setPartialText("");
}
} finally {
window.clearTimeout(timeout);
controllers.delete(controller);
if (controllers.size === 0) transcriptionControllersRef.current.delete(sessionId);
}
} catch { /* capture teardown remains successful when transcription cannot finish */ }
finally {
stoppingRef.current = false;
if (sessionRef.current === sessionId) sessionRef.current = undefined;
sequenceRef.current.delete(sessionId);
void fetch(`/api/voice/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch(() => undefined);
}
})();
}, [releaseCapture]);
return { supported, enabled, state, partialText, finalText, error, start, stop };
}