{
const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null)
@@ -200,13 +201,14 @@ const SmartTaskTitleInput = ({
e.target.value = ''
}
- const showNativeButtons = isNativeScanner && !value
- const MIC_BUTTON_WIDTH =
- showNativeButtons && onPhotoSelected && onScanClick
- ? '5rem'
- : showNativeButtons
- ? '2.5rem'
- : '0rem'
+ const showPhotoButtons = isNativeScanner && !value
+ const showVoiceButton = !!onVoiceClick && !value
+ const visibleButtonCount =
+ (showPhotoButtons && onPhotoSelected ? 1 : 0) +
+ (showPhotoButtons && onScanClick ? 1 : 0) +
+ (showVoiceButton ? 1 : 0)
+ const showActionButtons = visibleButtonCount > 0
+ const ACTION_BUTTONS_WIDTH = `${visibleButtonCount * 2.5}rem`
return (
@@ -223,7 +225,7 @@ const SmartTaskTitleInput = ({
position: 'absolute',
top: 0,
left: 0,
- width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
+ width: `calc(100% - ${ACTION_BUTTONS_WIDTH})`,
height: '100%',
zIndex: 1,
resize: 'none',
@@ -274,7 +276,7 @@ const SmartTaskTitleInput = ({
{/* Zero-width space to maintain consistent height */}
- {showNativeButtons && (
+ {showActionButtons && (
- {onPhotoSelected && (
+ {showPhotoButtons && onPhotoSelected && (
<>
>
)}
- {onScanClick && (
+ {showPhotoButtons && onScanClick && (
)}
+ {showVoiceButton && (
+
+
+
+
+
+ )}
)}
diff --git a/src/views/components/VoiceToTask/VoicePanel.css b/src/views/components/VoiceToTask/VoicePanel.css
new file mode 100644
index 0000000..f8648a7
--- /dev/null
+++ b/src/views/components/VoiceToTask/VoicePanel.css
@@ -0,0 +1,121 @@
+.voice-mic-btn {
+ position: relative;
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ background: var(--joy-palette-primary-solidBg, #0b6bcb);
+ color: #fff;
+ transition:
+ transform 0.15s ease,
+ background 0.2s ease,
+ box-shadow 0.2s ease;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.voice-mic-btn:active {
+ transform: scale(0.94);
+}
+
+.voice-mic-btn.listening {
+ background: var(--joy-palette-danger-solidBg, #c41c1c);
+ box-shadow: 0 4px 18px rgba(196, 28, 28, 0.35);
+}
+
+.voice-pulse-ring {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ pointer-events: none;
+ opacity: 0;
+}
+
+.voice-mic-btn.listening .voice-pulse-ring {
+ opacity: 1;
+ animation: voice-pulse 1.8s ease-out infinite;
+}
+
+.voice-mic-btn.listening .voice-pulse-ring:nth-child(2) {
+ animation-delay: 0.6s;
+}
+
+@keyframes voice-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(196, 28, 28, 0.4);
+ }
+ 100% {
+ box-shadow: 0 0 0 26px rgba(196, 28, 28, 0);
+ }
+}
+
+/* Faux equalizer shown while listening */
+.voice-eq {
+ display: flex;
+ gap: 3px;
+ align-items: center;
+ height: 28px;
+}
+
+.voice-eq span {
+ width: 4px;
+ border-radius: 2px;
+ background: var(--joy-palette-danger-solidBg, #c41c1c);
+ animation: voice-eq-wave 1.1s ease-in-out infinite;
+}
+
+.voice-eq span:nth-child(1) {
+ animation-delay: 0s;
+}
+.voice-eq span:nth-child(2) {
+ animation-delay: 0.18s;
+}
+.voice-eq span:nth-child(3) {
+ animation-delay: 0.32s;
+}
+.voice-eq span:nth-child(4) {
+ animation-delay: 0.12s;
+}
+.voice-eq span:nth-child(5) {
+ animation-delay: 0.26s;
+}
+
+@keyframes voice-eq-wave {
+ 0%,
+ 100% {
+ height: 6px;
+ }
+ 50% {
+ height: 24px;
+ }
+}
+
+/* Committed task cards slide in as segments are captured */
+.voice-task-card {
+ animation: voice-card-in 0.25s ease-out;
+}
+
+@keyframes voice-card-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .voice-mic-btn.listening .voice-pulse-ring,
+ .voice-eq span,
+ .voice-task-card {
+ animation: none;
+ }
+}
diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx
new file mode 100644
index 0000000..f271fa6
--- /dev/null
+++ b/src/views/components/VoiceToTask/VoicePanel.jsx
@@ -0,0 +1,493 @@
+import {
+ CalendarMonth,
+ Close,
+ Flag,
+ GraphicEq,
+ Lock,
+ Mic,
+ Person,
+ Repeat,
+ Sell,
+ Toll,
+ WarningAmber,
+} from '@mui/icons-material'
+import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
+import moment from 'moment'
+import { useMemo, useState } from 'react'
+import { parseVoiceTask } from './parseVoiceTask'
+import { useVoiceToTask } from './useVoiceToTask'
+import './VoicePanel.css'
+
+const HIGHLIGHT_CLASS = {
+ repeat: 'highlight-repeat',
+ priority: 'highlight-priority',
+ points: 'highlight-points',
+ assignee: 'highlight-assignee',
+ label: 'highlight-label',
+ dueDate: 'highlight-date',
+}
+
+const renderTranscript = (text, highlights) => {
+ const parts = []
+ let lastIndex = 0
+ for (const h of highlights) {
+ if (h.start > lastIndex) parts.push(text.substring(lastIndex, h.start))
+ parts.push(
+
+ {text.substring(h.start, h.end)}
+ ,
+ )
+ lastIndex = h.end
+ }
+ if (lastIndex < text.length) parts.push(text.substring(lastIndex))
+ return parts
+}
+
+const formatDue = dueDate => {
+ const m = moment(dueDate)
+ return m.format('HH:mm') === '23:59'
+ ? m.format('MMM D')
+ : m.format('MMM D, h:mm A')
+}
+
+const buildChips = (parsed, { members, currentUserId }) => {
+ const chips = []
+ if (parsed.dueDate) {
+ chips.push({
+ key: 'due',
+ color: 'warning',
+ icon: ,
+ label: formatDue(parsed.dueDate),
+ })
+ }
+ if (parsed.frequencyName) {
+ chips.push({
+ key: 'repeat',
+ color: 'success',
+ icon: ,
+ label: parsed.frequencyName,
+ })
+ }
+ if (parsed.priority > 0) {
+ chips.push({
+ key: 'priority',
+ color: 'danger',
+ icon: ,
+ label: `P${parsed.priority}`,
+ })
+ }
+ if (parsed.points != null) {
+ chips.push({
+ key: 'points',
+ color: 'primary',
+ icon: ,
+ label: `${parsed.points} pts`,
+ })
+ }
+ parsed.labelNames.forEach(name => {
+ chips.push({
+ key: `label-${name}`,
+ color: 'primary',
+ icon: ,
+ label: name,
+ })
+ })
+ if (parsed.isAnyone) {
+ chips.push({
+ key: 'assignee',
+ color: 'neutral',
+ icon: ,
+ label: 'Anyone',
+ })
+ } else if (
+ parsed.assignees.length > 0 &&
+ parsed.assignees[0].userId !== currentUserId
+ ) {
+ const member = members.find(m => m.userId === parsed.assignees[0].userId)
+ if (member) {
+ chips.push({
+ key: 'assignee',
+ color: 'neutral',
+ icon: ,
+ label: member.displayName,
+ })
+ }
+ }
+ return chips
+}
+
+const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
+ const [editing, setEditing] = useState(false)
+ const [draft, setDraft] = useState(segment.text)
+
+ const parsed = useMemo(
+ () => parseVoiceTask(segment.text, parseCtx),
+ [segment.text, parseCtx],
+ )
+ const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx])
+
+ const commitEdit = () => {
+ setEditing(false)
+ if (draft.trim() !== segment.text) onUpdate(draft)
+ }
+
+ return (
+
+
+ {editing ? (
+ setDraft(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitEdit()
+ if (e.key === 'Escape') {
+ setDraft(segment.text)
+ setEditing(false)
+ }
+ }}
+ onBlur={commitEdit}
+ sx={{ flex: 1 }}
+ />
+ ) : (
+ {
+ setDraft(segment.text)
+ setEditing(true)
+ }}
+ >
+ {parsed.title || segment.text}
+
+ )}
+
+
+
+
+ {chips.length > 0 && (
+
+ {chips.map(chip => (
+
+ {chip.label}
+
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
+ *
+ * Hold the mic to speak, or tap once for hands-free. Pauses and spoken
+ * separators ("also") split the transcript into task cards. A single captured
+ * task lands in the smart input for review; multiple tasks are created
+ * directly from the review list.
+ */
+const VoicePanel = ({
+ open,
+ userLabels = [],
+ members = [],
+ userProfile,
+ onClose,
+ onUseSingle,
+ onCreateMany,
+}) => {
+ const {
+ phase,
+ isLocked,
+ partialText,
+ segments,
+ micPressDown,
+ micPressUp,
+ startListening,
+ removeSegment,
+ updateSegment,
+ reset,
+ isNative,
+ } = useVoiceToTask({ members })
+ const [creating, setCreating] = useState(false)
+
+ const parseCtx = useMemo(
+ () => ({ userLabels, members, currentUserId: userProfile?.id }),
+ [userLabels, members, userProfile?.id],
+ )
+
+ const partialParsed = useMemo(
+ () => (partialText ? parseVoiceTask(partialText, parseCtx) : null),
+ [partialText, parseCtx],
+ )
+
+ if (!open) return null
+
+ const isListening = phase === 'listening'
+ const showActions = segments.length > 0 && !isListening && !creating
+
+ const handleCancel = () => {
+ reset()
+ onClose()
+ }
+
+ const handleCreateAll = async () => {
+ setCreating(true)
+ try {
+ await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx)))
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const micCaption = isListening
+ ? isLocked
+ ? 'Listening — tap to stop'
+ : 'Release to finish · quick tap locks hands-free'
+ : segments.length > 0
+ ? 'Hold to add another task'
+ : 'Hold to speak · quick tap for hands-free'
+
+ return (
+
+ {/* ── Header ── */}
+
+
+ Speak your tasks
+ {isNative && (
+ }
+ sx={{ ml: 'auto' }}
+ >
+ On-device
+
+ )}
+
+
+ {/* ── Permission denied ── */}
+ {phase === 'denied' && (
+
+
+
+
+ Microphone access is needed for voice capture. Enable it in your
+ device settings and try again.
+
+
+
+
+ )}
+
+ {/* ── Captured task cards ── */}
+ {segments.length > 0 && (
+
+ {segments.map(segment => (
+ removeSegment(segment.id)}
+ onUpdate={text => updateSegment(segment.id, text)}
+ />
+ ))}
+
+ )}
+
+ {/* ── Live transcript ── */}
+ {isListening && (
+
+
+ {partialText ? (
+
+ {renderTranscript(partialText, partialParsed?.highlights || [])}
+
+ ) : (
+
+ Listening…
+
+ )}
+
+
+ )}
+
+ {/* ── Mic stage ── */}
+ {phase !== 'denied' && (
+
+
+
+
+
+
+
+
+
+
+ {micCaption}
+
+
+ Pause or say “also” between tasks · say
+ “scratch that” to remove the last one
+
+
+ )}
+
+ {/* ── Footer ── */}
+
+
+
+ {creating && (
+
+ )}
+ {showActions &&
+ (segments.length === 1 ? (
+
+ ) : (
+
+ ))}
+
+
+
+ )
+}
+
+export default VoicePanel
diff --git a/src/views/components/VoiceToTask/parseVoiceTask.js b/src/views/components/VoiceToTask/parseVoiceTask.js
new file mode 100644
index 0000000..ad46871
--- /dev/null
+++ b/src/views/components/VoiceToTask/parseVoiceTask.js
@@ -0,0 +1,201 @@
+import * as chrono from 'chrono-node'
+import moment from 'moment'
+import { isPlusAccount } from '../../../utils/Helpers'
+import { generateUUID } from '../../../utils/UUID'
+import {
+ parseAssignees,
+ parseDueDate,
+ parseLabels,
+ parsePoints,
+ parsePriority,
+ parseRepeatV2,
+} from '../CustomParsers'
+
+// Pure equivalent of AddTaskModal.processText — parses one sentence into a
+// structured task (no state setters), preserving the same parser order and
+// sequential-cleanup behavior so voice and typed input stay consistent.
+
+const mapMembersForParsing = members =>
+ members.map(member => ({
+ userId: member.userId,
+ username:
+ member.username || member.displayName?.toLowerCase().replace(/\s+/g, ''),
+ displayName: member.displayName,
+ name: member.displayName,
+ id: member.userId,
+ }))
+
+// Merge overlapping highlight ranges, higher parser priority wins — same
+// resolution rules as AddTaskModal.renderHighlightedSentence.
+const resolveHighlights = ({
+ repeat,
+ priority,
+ points,
+ assignees,
+ labels,
+ dueDate,
+}) => {
+ const all = []
+ repeat?.forEach(h => all.push({ ...h, type: 'repeat', rank: 60 }))
+ priority?.forEach(h => all.push({ ...h, type: 'priority', rank: 50 }))
+ points?.forEach(h => all.push({ ...h, type: 'points', rank: 45 }))
+ assignees?.forEach(h => all.push({ ...h, type: 'assignee', rank: 40 }))
+ labels?.forEach(h => all.push({ ...h, type: 'label', rank: 30 }))
+ if (dueDate) all.push({ ...dueDate, type: 'dueDate', rank: 20 })
+
+ all.sort((a, b) => a.start - b.start)
+ const resolved = []
+ for (const current of all) {
+ const previous = resolved[resolved.length - 1]
+ if (previous && current.start < previous.end) {
+ if (current.rank > previous.rank) {
+ resolved.pop()
+ resolved.push(current)
+ }
+ } else {
+ resolved.push(current)
+ }
+ }
+ return resolved
+}
+
+export const parseVoiceTask = (
+ sentence,
+ { userLabels = [], members = [], currentUserId = null } = {},
+) => {
+ const assigneesForParsing = mapMembersForParsing(members)
+
+ const priority = parsePriority(sentence)
+ const points = parsePoints(sentence)
+ const labels = parseLabels(sentence, userLabels)
+ const assigneesResult = parseAssignees(sentence, assigneesForParsing)
+ const repeat = parseRepeatV2(sentence)
+ const dueDateParsed = parseDueDate(sentence, chrono)
+
+ // Sequential cleanup — identical chain to AddTaskModal.processText
+ let cleaned = sentence
+ if (priority.result) cleaned = priority.cleanedSentence
+ if (points.result) {
+ const reparse = parsePoints(cleaned)
+ if (reparse.result) cleaned = reparse.cleanedSentence
+ }
+ if (labels.result) {
+ const reparse = parseLabels(cleaned, userLabels)
+ if (reparse.result) cleaned = reparse.cleanedSentence
+ }
+ if (assigneesResult.result) {
+ const reparse = parseAssignees(cleaned, assigneesForParsing)
+ if (reparse.result) cleaned = reparse.cleanedSentence
+ }
+ if (repeat.result) {
+ const reparse = parseRepeatV2(cleaned)
+ if (reparse.result) cleaned = reparse.cleanedSentence
+ }
+ if (dueDateParsed.result) {
+ const reparse = parseDueDate(cleaned, chrono)
+ if (reparse.result) cleaned = reparse.cleanedSentence
+ }
+
+ let dueDate = null
+ if (dueDateParsed.result) {
+ dueDate = moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')
+ } else if (repeat.dueDate) {
+ dueDate = moment(repeat.dueDate).format('YYYY-MM-DDTHH:mm:ss')
+ }
+
+ let assignees = []
+ const isAnyone = !!assigneesResult.isAnyone
+ if (!isAnyone) {
+ if (assigneesResult.result?.length > 0) {
+ assignees = assigneesResult.result.map(a => ({ userId: a.userId }))
+ } else if (currentUserId) {
+ assignees = [{ userId: currentUserId }]
+ }
+ }
+
+ const labelIds = (labels.result || [])
+ .filter(label => label.id)
+ .map(label => label.id)
+
+ return {
+ raw: sentence,
+ title: cleaned.replace(/\s+/g, ' ').trim(),
+ priority: priority.result ? parseInt(priority.result, 10) : 0,
+ points: points.result ?? null,
+ labelIds,
+ labelNames: (labels.result || []).map(label => label.name),
+ assignees,
+ isAnyone,
+ frequency: repeat.result,
+ frequencyName: repeat.name,
+ dueDate,
+ highlights: resolveHighlights({
+ repeat: repeat.highlight,
+ priority: priority.highlight,
+ points: points.highlight,
+ assignees: assigneesResult.highlight,
+ labels: labels.highlight,
+ dueDate: dueDateParsed.result ? dueDateParsed.highlight[0] : null,
+ }),
+ }
+}
+
+// Builds the same chore payload shape AddTaskModal.createChore submits.
+export const buildChorePayload = (
+ parsed,
+ { userProfile, projectId, notificationTemplates },
+) => {
+ let finalAssignees = parsed.assignees
+ let finalAssignedTo = null
+ let finalAssignStrategy = 'keep_last_assigned'
+
+ if (parsed.isAnyone) {
+ finalAssignees = []
+ finalAssignStrategy = 'no_assignee'
+ } else if (finalAssignees.length === 0) {
+ finalAssignees = [{ userId: userProfile?.id }]
+ finalAssignedTo = userProfile?.id
+ } else {
+ finalAssignedTo = finalAssignees[0].userId
+ }
+
+ const chore = {
+ name: parsed.title,
+ description: null,
+ assignees: finalAssignees,
+ dueDate: parsed.dueDate ? new Date(parsed.dueDate).toISOString() : null,
+ assignedTo: finalAssignedTo,
+ assignStrategy: finalAssignStrategy,
+ isRolling: false,
+ labelsV2: parsed.labelIds,
+ priority: parsed.priority || 0,
+ points: parsed.points ?? null,
+ deadlineOffset: null,
+ completionWindow: null,
+ requireApproval: false,
+ isPrivate: false,
+ status: 0,
+ frequencyType: 'once',
+ frequencyMetadata: {},
+ notificationMetadata: {},
+ subTasks: null,
+ projectId: projectId === 'default' ? null : projectId,
+ draftId: generateUUID(),
+ }
+
+ if (parsed.frequency) {
+ chore.frequencyType = parsed.frequency.frequencyType
+ chore.frequencyMetadata = parsed.frequency.frequencyMetadata
+ chore.frequency = parsed.frequency.frequency
+ if (isPlusAccount(userProfile)) {
+ chore.notification = true
+ chore.notificationMetadata = { templates: notificationTemplates }
+ }
+ }
+ if (!parsed.frequency && parsed.dueDate) {
+ chore.nextDueDate = new Date(parsed.dueDate).toISOString()
+ chore.notificationMetadata = { templates: notificationTemplates }
+ }
+
+ return chore
+}
diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js
new file mode 100644
index 0000000..45cf0fe
--- /dev/null
+++ b/src/views/components/VoiceToTask/useVoiceToTask.js
@@ -0,0 +1,217 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { voiceInputService } from '../../../service/VoiceInputService'
+import { generateUUID } from '../../../utils/UUID'
+import {
+ applyScratchThat,
+ normalizeSpokenText,
+ splitSpokenSegments,
+} from './voiceNormalizer'
+
+// Mic gesture: hold = push-to-talk (release stops), quick tap = hands-free
+// lock (tap again to stop). In hands-free mode, sustained silence auto-stops
+// into review so the user is never stuck watching a live mic.
+
+const TAP_THRESHOLD_MS = 400
+const HANDS_FREE_SILENCE_STOP_MS = 8000
+const HANDS_FREE_EMPTY_STOP_MS = 20000
+
+const haptic = async kind => {
+ try {
+ const { Haptics, ImpactStyle, NotificationType } = await import(
+ '@capacitor/haptics'
+ )
+ if (kind === 'notification') {
+ await Haptics.notification({ type: NotificationType.Success })
+ } else if (kind === 'medium') {
+ await Haptics.impact({ style: ImpactStyle.Medium })
+ } else {
+ await Haptics.impact({ style: ImpactStyle.Light })
+ }
+ } catch {
+ // no haptics on this platform
+ }
+}
+
+// phases: idle | listening | review | denied
+export function useVoiceToTask({ members = [] } = {}) {
+ const [phase, setPhase] = useState('idle')
+ const [isLocked, setIsLocked] = useState(false)
+ const [partialText, setPartialText] = useState('')
+ const [segments, setSegments] = useState([])
+
+ // Kept in sync manually (not via render) so segment commits that happen
+ // inside voiceInputService.stop() are visible immediately afterwards.
+ const segmentsRef = useRef([])
+ const membersRef = useRef(members)
+ membersRef.current = members
+
+ const phaseRef = useRef(phase)
+ phaseRef.current = phase
+ const lockedRef = useRef(isLocked)
+ lockedRef.current = isLocked
+
+ const pressStartedAtRef = useRef(0)
+ const pressStartedListeningRef = useRef(false)
+ const lastActivityRef = useRef(0)
+ const watchdogRef = useRef(null)
+
+ const applySegments = useCallback(next => {
+ segmentsRef.current = next
+ setSegments(next)
+ }, [])
+
+ const commitSegment = useCallback(
+ rawText => {
+ const normalized = normalizeSpokenText(rawText, {
+ members: membersRef.current,
+ })
+ const { text, dropPrevious } = applyScratchThat(normalized)
+ const pieces = splitSpokenSegments(text)
+ if (!dropPrevious && pieces.length === 0) return
+
+ let base = segmentsRef.current
+ if (dropPrevious && base.length > 0) {
+ base = base.slice(0, -1)
+ haptic('medium')
+ }
+ if (pieces.length > 0) haptic('light')
+ applySegments([
+ ...base,
+ ...pieces.map(piece => ({ id: generateUUID(), text: piece })),
+ ])
+ },
+ [applySegments],
+ )
+
+ const stopListening = useCallback(async () => {
+ if (watchdogRef.current) {
+ clearInterval(watchdogRef.current)
+ watchdogRef.current = null
+ }
+ await voiceInputService.stop()
+ setPartialText('')
+ setIsLocked(false)
+ // stop() commits any buffered partial synchronously through onSegment,
+ // so the ref is up to date by the time we read it
+ setPhase(segmentsRef.current.length > 0 ? 'review' : 'idle')
+ haptic('light')
+ }, [])
+
+ const startListening = useCallback(async () => {
+ const permission = await voiceInputService.requestPermission()
+ if (permission !== 'granted') {
+ setPhase('denied')
+ return false
+ }
+ lastActivityRef.current = Date.now()
+ await voiceInputService.start({
+ onPartial: text => {
+ lastActivityRef.current = Date.now()
+ setPartialText(
+ normalizeSpokenText(text, { members: membersRef.current }),
+ )
+ },
+ onSegment: commitSegment,
+ onError: () => {
+ setPhase('denied')
+ },
+ onStateChange: () => {},
+ })
+ setPhase('listening')
+ haptic('medium')
+
+ // Hands-free: auto-stop into review after sustained silence
+ watchdogRef.current = setInterval(() => {
+ if (phaseRef.current !== 'listening' || !lockedRef.current) return
+ const idleFor = Date.now() - lastActivityRef.current
+ const limit =
+ segmentsRef.current.length > 0
+ ? HANDS_FREE_SILENCE_STOP_MS
+ : HANDS_FREE_EMPTY_STOP_MS
+ if (idleFor > limit) {
+ stopListening()
+ }
+ }, 1000)
+ return true
+ }, [commitSegment, stopListening])
+
+ const micPressDown = useCallback(() => {
+ pressStartedAtRef.current = Date.now()
+ if (phaseRef.current === 'listening') {
+ pressStartedListeningRef.current = false
+ return
+ }
+ pressStartedListeningRef.current = true
+ startListening()
+ }, [startListening])
+
+ const micPressUp = useCallback(() => {
+ const held = Date.now() - pressStartedAtRef.current
+ if (pressStartedListeningRef.current) {
+ if (held < TAP_THRESHOLD_MS) {
+ // Quick tap → hands-free lock
+ setIsLocked(true)
+ } else {
+ // Hold-to-talk → release ends the capture
+ stopListening()
+ }
+ } else if (phaseRef.current === 'listening') {
+ // Tap while already listening (locked mode) → stop
+ stopListening()
+ }
+ pressStartedListeningRef.current = false
+ }, [stopListening])
+
+ const removeSegment = useCallback(
+ id => {
+ applySegments(segmentsRef.current.filter(s => s.id !== id))
+ },
+ [applySegments],
+ )
+
+ const updateSegment = useCallback(
+ (id, text) => {
+ applySegments(
+ segmentsRef.current
+ .map(s => (s.id === id ? { ...s, text: text.trim() } : s))
+ .filter(s => s.text),
+ )
+ },
+ [applySegments],
+ )
+
+ const reset = useCallback(() => {
+ voiceInputService.stop()
+ if (watchdogRef.current) {
+ clearInterval(watchdogRef.current)
+ watchdogRef.current = null
+ }
+ applySegments([])
+ setPartialText('')
+ setIsLocked(false)
+ setPhase('idle')
+ }, [applySegments])
+
+ // Stop the recognizer if the panel unmounts mid-capture
+ useEffect(() => {
+ return () => {
+ voiceInputService.stop()
+ if (watchdogRef.current) clearInterval(watchdogRef.current)
+ }
+ }, [])
+
+ return {
+ phase,
+ isLocked,
+ partialText,
+ segments,
+ micPressDown,
+ micPressUp,
+ startListening,
+ stopListening,
+ removeSegment,
+ updateSegment,
+ reset,
+ isNative: voiceInputService.isNative,
+ }
+}
diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js
new file mode 100644
index 0000000..1fffc8d
--- /dev/null
+++ b/src/views/components/VoiceToTask/voiceNormalizer.js
@@ -0,0 +1,129 @@
+// Deterministic transforms that turn spoken language into the typed syntax
+// CustomParsers understands. No LLM — instant, predictable, fully offline.
+//
+// "label groceries" → "#groceries"
+// "assign to Sarah" → "@Sarah" (only when Sarah is a circle member)
+// "worth five points" → "*5"
+// "p one" / "top priority" → "priority 1" (parsePriority already handles that)
+
+const FILLER_REGEX = /(?:^|\s)(?:um+|uh+|erm+|hmm+|mmm+)(?=[\s,.!?]|$)[,.]?/gi
+
+const NUMBER_WORDS = {
+ one: 1,
+ two: 2,
+ three: 3,
+ four: 4,
+ five: 5,
+ six: 6,
+ seven: 7,
+ eight: 8,
+ nine: 9,
+ ten: 10,
+ fifteen: 15,
+ twenty: 20,
+ 'twenty five': 25,
+ 'twenty-five': 25,
+ fifty: 50,
+ hundred: 100,
+ 'one hundred': 100,
+}
+
+const NUMBER_WORD_PATTERN = Object.keys(NUMBER_WORDS)
+ // Longest first so "twenty five" wins over "five"
+ .sort((a, b) => b.length - a.length)
+ .join('|')
+
+const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+export const stripFillers = text =>
+ text.replace(FILLER_REGEX, ' ').replace(/\s+/g, ' ').trim()
+
+const normalizePriority = text =>
+ text
+ .replace(/\b(?:top|highest)\s+priority\b/gi, 'priority 1')
+ .replace(
+ /\bp[\s-]?(one|two|three|four|[1-4])\b/gi,
+ (_, n) => `priority ${NUMBER_WORDS[n.toLowerCase()] || n}`,
+ )
+
+const normalizePoints = text =>
+ text.replace(
+ new RegExp(
+ `\\b(?:worth\\s+)?(\\d+|${NUMBER_WORD_PATTERN})\\s+points?\\b`,
+ 'gi',
+ ),
+ (_, n) => `*${NUMBER_WORDS[n.toLowerCase()] || n} points`,
+ )
+
+const normalizeLabels = text =>
+ text.replace(
+ /\b(?:with\s+)?(?:hash\s?tag|labell?ed(?:\s+as)?|label|tagged(?:\s+as)?|tag)\s+([\p{L}\p{N}_]+)/giu,
+ '#$1',
+ )
+
+const normalizeAssignees = (text, members = []) => {
+ const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)'
+ let out = text.replace(
+ new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'),
+ '@Anyone',
+ )
+
+ for (const member of members) {
+ const displayName = member.displayName
+ if (!displayName) continue
+ const firstName = displayName.split(/\s+/)[0]
+ // Full display name first so "assign to Mo Tarbin" doesn't leave "Tarbin"
+ const names = [...new Set([displayName, firstName])].filter(
+ n => n.length > 1,
+ )
+ for (const name of names) {
+ out = out.replace(
+ new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'),
+ `@${displayName}`,
+ )
+ }
+ }
+ return out
+}
+
+export const normalizeSpokenText = (text, { members = [] } = {}) => {
+ let out = stripFillers(text)
+ out = normalizePriority(out)
+ out = normalizePoints(out)
+ out = normalizeLabels(out)
+ out = normalizeAssignees(out, members)
+ return out.replace(/\s+/g, ' ').trim()
+}
+
+// ── Multi-task segmentation ─────────────────────────────────────────────────
+// A pause (utterance boundary) always splits — that's handled upstream by the
+// recognizer. These spoken separators additionally split within one utterance.
+// Deliberately conservative: "and then" is NOT a separator ("wash and then
+// fold laundry" is one task).
+
+const SEPARATOR_REGEX =
+ /\s*\b(?:and\s+also|also|next\s+task|new\s+task|another\s+task)\b[,.]?\s*/gi
+
+export const splitSpokenSegments = text =>
+ text
+ .split(SEPARATOR_REGEX)
+ .map(s => s.trim().replace(/^[,.]\s*/, ''))
+ .filter(Boolean)
+
+// ── "Scratch that" correction ───────────────────────────────────────────────
+// Everything spoken before the command dies. If the command opens the
+// utterance ("…pause… scratch that"), the previously committed task dies
+// instead. Words after the command carry on as the replacement.
+
+const SCRATCH_REGEX =
+ /\s*\b(?:(?:scratch|forget|delete|remove|cancel)\s+(?:that|this|it|last(?:\s+one)?)|never\s?mind)\b[,.]?\s*/gi
+
+export const applyScratchThat = text => {
+ const parts = text.split(SCRATCH_REGEX)
+ if (parts.length === 1) {
+ return { text: text.trim(), dropPrevious: false }
+ }
+ const before = parts.slice(0, -1).join(' ').trim()
+ const after = parts[parts.length - 1].trim()
+ return { text: after, dropPrevious: before.length === 0 }
+}