Files
fusion/packages/dashboard/app/components/TaskComments.tsx
gsxdsm 32adc0a153 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>
2026-07-25 05:15:02 -07:00

216 lines
8.4 KiB
TypeScript

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";
import { getErrorMessage } from "@fusion/core";
import "./TaskComments.css";
import { addSteeringComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";
const MAX_COMMENT_LENGTH = 2000;
interface TaskCommentsProps {
task: Task;
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
currentAuthor?: string;
projectId?: string;
}
function formatCommentTimestamp(comment: TaskComment, t: TFunction<"app">): string {
const timestamp = comment.updatedAt || comment.createdAt;
const label = new Date(timestamp).toLocaleString();
return comment.updatedAt ? `${label} ${t("comments.editedSuffix", "(edited)")}` : label;
}
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);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Sort comments by createdAt descending (newest first)
const comments = useMemo(() => {
return [...(task.comments || [])].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}, [task.comments]);
const isOverLimit = draft.length > MAX_COMMENT_LENGTH;
async function handleAddComment() {
const text = draft.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await addSteeringComment(task.id, text, projectId);
setDraft("");
onTaskUpdated?.(updated);
addToast(t("comments.addedSuccess", "Comment added"), "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to add comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleSaveEdit(commentId: string) {
const text = editingText.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await updateTaskComment(task.id, commentId, text, projectId);
setEditingId(null);
setEditingText("");
onTaskUpdated?.(updated);
addToast(t("comments.updatedSuccess", "Comment updated"), "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to update comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId);
try {
const updated = await deleteTaskComment(task.id, commentId, projectId);
onTaskUpdated?.(updated);
addToast(t("comments.deletedSuccess", "Comment deleted"), "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to delete comment", "error");
} finally {
setDeletingId(null);
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void handleAddComment();
}
}
const placeholder = t("comments.placeholder", "Add a comment");
const buttonLabel = t("comments.addButton", "Add Comment");
return (
<div className="detail-section">
<h4>{t("comments.heading", "Comments")}</h4>
{comments.length === 0 ? (
<div className="detail-log-empty">{t("comments.emptyState", "No comments yet.")}</div>
) : (
<div className="detail-activity-list">
{comments.map((comment) => {
const canEdit = comment.author === currentAuthor;
const isEditing = editingId === comment.id;
const isAIGuidance = isAIGuidanceComment(comment.author);
return (
<div key={comment.id} className="detail-log-entry">
<div className="detail-log-header comments-header-row">
<div className="comments-author-row">
{isAIGuidance ? (
<span className="ai-guidance-badge" data-testid="ai-guidance-badge">{t("comments.aiGuidance", "AI Guidance")}</span>
) : (
<strong>{comment.author}</strong>
)}
<span className="detail-log-timestamp">
{formatCommentTimestamp(comment, t)}
</span>
</div>
{canEdit && !isEditing ? (
<div className="comments-actions-row">
<button className="btn btn-sm" onClick={() => {
setEditingId(comment.id);
setEditingText(comment.text);
}}>
{t("actions.edit", "Edit")}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => void handleDelete(comment.id)}
disabled={deletingId === comment.id}
>
{deletingId === comment.id ? t("comments.deletingButton", "Deleting…") : t("actions.delete", "Delete")}
</button>
</div>
) : null}
</div>
{isEditing ? (
<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}
</div>
)}
</div>
);
})}
</div>
)}
<div className="comments-compose-form">
<textarea
ref={draftRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder={placeholder}
className="comments-textarea"
/>
<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>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleAddComment()}
disabled={submitting || !draft.trim() || isOverLimit}
>
{submitting ? t("comments.postingButton", "Posting…") : buttonLabel}
</button>
</div>
</div>
</div>
);
}