{
}
: undefined
}
+ onVoiceClick={
+ voiceAvailable ? () => setShowVoice(true) : undefined
+ }
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
@@ -1115,6 +1217,17 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
>
)}
+ {showVoice && (
+
+ )}
+
{showScan && (
{
- const [isOpen, setIsOpen] = useState(false)
+ const [internalOpen, setInternalOpen] = useState(false)
+ const isControlled = openProp !== undefined
+ const isOpen = isControlled ? openProp : internalOpen
+ const setIsOpen = value => {
+ if (!isControlled) setInternalOpen(value)
+ const nextValue = typeof value === 'function' ? value(isOpen) : value
+ onOpenChange?.(nextValue)
+ }
const buttonRef = useRef(null)
useEffect(() => {
diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx
index ff27a4a..499640c 100644
--- a/src/views/components/LabelsPickerField.jsx
+++ b/src/views/components/LabelsPickerField.jsx
@@ -12,6 +12,7 @@ const LabelsPickerField = ({
emptyDisplay = 'icon-text',
}) => {
const [createOpen, setCreateOpen] = useState(false)
+ const [pickerOpen, setPickerOpen] = useState(false)
const options = labels.map(label => ({
id: label.id,
@@ -27,6 +28,8 @@ const LabelsPickerField = ({
values={values}
onValuesChange={onChange}
onClear={onClear}
+ open={pickerOpen}
+ onOpenChange={setPickerOpen}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
@@ -55,6 +58,7 @@ const LabelsPickerField = ({
startDecorator={}
onClick={e => {
e.stopPropagation()
+ setPickerOpen(false)
setCreateOpen(true)
}}
sx={{ width: '100%', justifyContent: 'flex-start' }}
diff --git a/src/views/components/ScanToTask/ScanPanel.jsx b/src/views/components/ScanToTask/ScanPanel.jsx
index 4732193..be7aa02 100644
--- a/src/views/components/ScanToTask/ScanPanel.jsx
+++ b/src/views/components/ScanToTask/ScanPanel.jsx
@@ -171,9 +171,6 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
/>
-
{isNativeScanner ? (
-
)}
diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx
index 9558b5f..0c9f12c 100644
--- a/src/views/components/SmartTaskTitleInput.jsx
+++ b/src/views/components/SmartTaskTitleInput.jsx
@@ -1,4 +1,4 @@
-import { CameraEnhance, PhotoFilter } from '@mui/icons-material'
+import { CameraEnhance, Mic, PhotoFilter } from '@mui/icons-material'
import { IconButton, Tooltip, useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
@@ -57,6 +57,7 @@ const SmartTaskTitleInput = ({
isNativeScanner,
onScanClick,
onPhotoSelected,
+ onVoiceClick,
}) => {
const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null)
@@ -225,13 +226,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 (
@@ -248,7 +250,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',
@@ -299,7 +301,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..7d6bae3
--- /dev/null
+++ b/src/views/components/VoiceToTask/VoicePanel.jsx
@@ -0,0 +1,690 @@
+import {
+ CalendarMonth,
+ Check,
+ 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 { useEffect, useMemo, useRef, useState } from 'react'
+import { TASK_COLOR } from '../../../utils/Colors'
+import AssigneePickerField from '../AssigneePickerField'
+import DueDatePickerField from '../DueDatePickerField'
+import LabelsPickerField from '../LabelsPickerField'
+import PriorityPickerField from '../PriorityPickerField'
+import RepeatPickerField from '../RepeatPickerField'
+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 PRIORITY_COLORS = {
+ 0: TASK_COLOR.NO_PRIORITY,
+ 1: TASK_COLOR.PRIORITY_1,
+ 2: TASK_COLOR.PRIORITY_2,
+ 3: TASK_COLOR.PRIORITY_3,
+ 4: TASK_COLOR.PRIORITY_4,
+}
+
+const PRIORITY_LABELS = {
+ 0: '--',
+ 1: 'P1',
+ 2: 'P2',
+ 3: 'P3',
+ 4: 'P4',
+}
+
+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')
+}
+
+// Compact description for picker-overridden frequencies where the parser's
+// human name no longer applies
+const describeFrequency = f => {
+ if (!f) return null
+ if (f.frequencyType === 'interval') {
+ const unit = f.frequencyMetadata?.unit || 'days'
+ return f.frequency > 1
+ ? `Every ${f.frequency} ${unit}`
+ : `Every ${unit.replace(/s$/, '')}`
+ }
+ const names = {
+ daily: 'Daily',
+ weekly: 'Weekly',
+ monthly: 'Monthly',
+ yearly: 'Yearly',
+ days_of_the_week: 'Custom days',
+ day_of_the_month: 'Monthly',
+ }
+ return names[f.frequencyType] || 'Repeats'
+}
+
+const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
+ const chips = []
+ if (effective.dueDate) {
+ chips.push({
+ key: 'due',
+ color: 'warning',
+ icon: ,
+ label: formatDue(effective.dueDate),
+ })
+ }
+ if (frequencyLabel) {
+ chips.push({
+ key: 'repeat',
+ color: 'success',
+ icon: ,
+ label: frequencyLabel,
+ })
+ }
+ if (effective.priority > 0) {
+ chips.push({
+ key: 'priority',
+ color: 'danger',
+ icon: ,
+ label: `P${effective.priority}`,
+ })
+ }
+ if (effective.points != null) {
+ chips.push({
+ key: 'points',
+ color: 'primary',
+ icon: ,
+ label: `${effective.points} pts`,
+ })
+ }
+ effective.labelNames.forEach(name => {
+ chips.push({
+ key: `label-${name}`,
+ color: 'primary',
+ icon: ,
+ label: name,
+ })
+ })
+ if (effective.isAnyone) {
+ chips.push({
+ key: 'assignee',
+ color: 'neutral',
+ icon: ,
+ label: 'Anyone',
+ })
+ } else if (
+ effective.assignees.length > 0 &&
+ effective.assignees[0].userId !== currentUserId
+ ) {
+ const member = members.find(m => m.userId === effective.assignees[0].userId)
+ if (member) {
+ chips.push({
+ key: 'assignee',
+ color: 'neutral',
+ icon: ,
+ label: member.displayName,
+ })
+ }
+ }
+ return chips
+}
+
+const TaskPreviewCard = ({
+ segment,
+ parseCtx,
+ onRemove,
+ onUpdate,
+ onPatch,
+}) => {
+ const [expanded, setExpanded] = useState(false)
+ const [draft, setDraft] = useState(segment.text)
+ const dueEditRef = useRef(null)
+
+ const parsed = useMemo(
+ () => parseVoiceTask(segment.text, parseCtx),
+ [segment.text, parseCtx],
+ )
+ const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
+ const effective = useMemo(
+ () => ({ ...parsed, ...overrides }),
+ [parsed, overrides],
+ )
+
+ const frequencyLabel =
+ 'frequency' in overrides
+ ? describeFrequency(effective.frequency)
+ : parsed.frequencyName
+ const chips = useMemo(
+ () => buildChips(effective, frequencyLabel, parseCtx),
+ [effective, frequencyLabel, parseCtx],
+ )
+
+ const due = effective.dueDate ? moment(effective.dueDate) : null
+ const dueDateOnly = due ? due.format('YYYY-MM-DD') : null
+ const hasCustomTime = !!due && due.format('HH:mm') !== '23:59'
+ const dueTime = hasCustomTime ? due.format('HH:mm') : null
+
+ // DueDatePickerField's Apply fires date/custom-time/time callbacks in
+ // sequence; collect them in one microtask so they land as a single patch
+ const queueDuePatch = patch => {
+ if (!dueEditRef.current) {
+ dueEditRef.current = {
+ date: dueDateOnly,
+ time: dueTime,
+ custom: hasCustomTime,
+ }
+ queueMicrotask(() => {
+ const { date, time, custom } = dueEditRef.current
+ dueEditRef.current = null
+ if (!date) {
+ onPatch({ dueDate: null })
+ } else {
+ onPatch({
+ dueDate:
+ custom && time
+ ? moment(`${date}T${time}`).format('YYYY-MM-DDTHH:mm:00')
+ : moment(date).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
+ })
+ }
+ })
+ }
+ Object.assign(dueEditRef.current, patch)
+ }
+
+ const commitText = () => {
+ if (draft.trim() !== segment.text) onUpdate(draft)
+ }
+
+ return (
+
+
+ {expanded ? (
+ setDraft(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') commitText()
+ if (e.key === 'Escape') setDraft(segment.text)
+ }}
+ onBlur={commitText}
+ sx={{ flex: 1 }}
+ />
+ ) : (
+ {
+ setDraft(segment.text)
+ setExpanded(true)
+ }}
+ >
+ {parsed.title || segment.text}
+
+ )}
+ {expanded && (
+ {
+ commitText()
+ setExpanded(false)
+ }}
+ sx={{ '--IconButton-size': '28px' }}
+ >
+
+
+ )}
+
+
+
+
+
+ {!expanded && chips.length > 0 && (
+ {
+ setDraft(segment.text)
+ setExpanded(true)
+ }}
+ >
+ {chips.map(chip => (
+
+ {chip.label}
+
+ ))}
+
+ )}
+
+ {expanded && (
+
+
+ queueDuePatch({ date: e.target.value || null })
+ }
+ onDueTimeChange={e =>
+ queueDuePatch({ time: e.target.value || null })
+ }
+ onUseCustomTimeChange={checked =>
+ queueDuePatch({ custom: checked })
+ }
+ onClear={() => onPatch({ dueDate: null })}
+ />
+ onPatch({ frequency: f })}
+ onClear={() => onPatch({ frequency: null })}
+ />
+ onPatch({ priority: p })}
+ onClear={() => onPatch({ priority: 0 })}
+ priorityColors={PRIORITY_COLORS}
+ priorityLabels={PRIORITY_LABELS}
+ />
+ a.userId)}
+ isAnyone={effective.isAnyone}
+ onChange={userIds => {
+ if (userIds.includes('anyone')) {
+ onPatch({ isAnyone: true, assignees: [] })
+ } else {
+ onPatch({
+ isAnyone: false,
+ assignees: userIds.map(userId => ({ userId })),
+ })
+ }
+ }}
+ onClear={() => onPatch({ isAnyone: false, assignees: [] })}
+ currentUserId={parseCtx.currentUserId}
+ members={parseCtx.members}
+ />
+ onPatch({ labelIds: ids })}
+ onClear={() => onPatch({ labelIds: [] })}
+ labels={parseCtx.userLabels}
+ />
+
+ )}
+
+ )
+}
+
+/**
+ * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
+ *
+ * Opens straight into hands-free listening. Pauses and spoken separators
+ * ("also") split the transcript into task cards; tapping a card opens inline
+ * pickers whose edits override the parsed values. A single captured task
+ * lands in the smart input for review; multiple are created directly.
+ */
+const VoicePanel = ({
+ open,
+ userLabels = [],
+ members = [],
+ userProfile,
+ onUseSingle,
+ onCreateMany,
+}) => {
+ const {
+ phase,
+ isLocked,
+ partialText,
+ segments,
+ micPressDown,
+ micPressUp,
+ startHandsFree,
+ removeSegment,
+ updateSegment,
+ patchSegment,
+ isNative,
+ } = useVoiceToTask({ members, userLabels })
+ const [creating, setCreating] = useState(false)
+ const autoStartedRef = useRef(false)
+ const segmentsScrollRef = useRef(null)
+
+ const parseCtx = useMemo(
+ () => ({ userLabels, members, currentUserId: userProfile?.id }),
+ [userLabels, members, userProfile?.id],
+ )
+
+ const partialParsed = useMemo(
+ () => (partialText ? parseVoiceTask(partialText, parseCtx) : null),
+ [partialText, parseCtx],
+ )
+
+ // Start capturing the moment the panel opens — the mic tap that opened it
+ // is the only tap needed
+ useEffect(() => {
+ if (open && !autoStartedRef.current) {
+ autoStartedRef.current = true
+ startHandsFree()
+ }
+ }, [open, startHandsFree])
+
+ // Keep the newest captured task visible as more are added
+ useEffect(() => {
+ const el = segmentsScrollRef.current
+ if (el) el.scrollTop = el.scrollHeight
+ }, [segments.length])
+
+ if (!open) return null
+
+ const isListening = phase === 'listening'
+ const showActions = segments.length > 0 && !isListening && !creating
+
+ const mergedTask = segment => ({
+ ...parseVoiceTask(segment.text, parseCtx),
+ ...(segment.overrides || {}),
+ })
+
+ const handleCreateAll = async () => {
+ setCreating(true)
+ try {
+ await onCreateMany(segments.map(mergedTask))
+ } 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)}
+ onPatch={patch => patchSegment(segment.id, patch)}
+ />
+ ))}
+
+ )}
+
+ {/* ── Live transcript ── */}
+ {isListening && (
+
+
+ {partialText ? (
+
+ {renderTranscript(partialText, partialParsed?.highlights || [])}
+
+ ) : (
+
+ Listening…
+
+ )}
+
+
+ )}
+
+ {/* ── Mic stage ── */}
+ {phase !== 'denied' && (
+
+
+
+
+
+
+
+
+
+
+ {micCaption}
+
+
+ Pause between tasks · say “scratch that” to
+ remove the last one
+
+
+ )}
+
+ {/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
+ {(creating || showActions) && (
+
+ {creating ? (
+
+ ) : 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..69352f6
--- /dev/null
+++ b/src/views/components/VoiceToTask/useVoiceToTask.js
@@ -0,0 +1,313 @@
+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
+ }
+}
+
+// Vocabulary fed to the native recognizer as a biasing hint so unfamiliar
+// names/labels aren't auto-corrected to a dictionary word (e.g. "Moutaz" →
+// "Models"). Best-effort only — unsupported on iOS <13-without-on-device and
+// Android <13, which is why the normalizer also does fuzzy post-matching.
+const buildVocabulary = (members, userLabels) => [
+ ...members.flatMap(m =>
+ [m.displayName, m.displayName?.split(/\s+/)[0], m.username].filter(Boolean),
+ ),
+ ...userLabels.map(l => l.name).filter(Boolean),
+]
+
+// phases: idle | listening | review | denied
+export function useVoiceToTask({ members = [], userLabels = [] } = {}) {
+ 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 userLabelsRef = useRef(userLabels)
+ userLabelsRef.current = userLabels
+ const vocabularyRef = useRef(buildVocabulary(members, userLabels))
+ vocabularyRef.current = buildVocabulary(members, userLabels)
+
+ 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)
+ // While the mic is held (not locked), a mid-hold restart (Android session
+ // limits, forced silence boundary) shouldn't split into a new task — the
+ // user is still holding the button, so it's still one entry. This tracks
+ // which segment is the "active" one for the current hold to merge onto;
+ // reset to null on release so the *next* hold starts a fresh entry.
+ const activeHoldSegmentIdRef = useRef(null)
+
+ const applySegments = useCallback(next => {
+ segmentsRef.current = next
+ setSegments(next)
+ }, [])
+
+ const commitSegment = useCallback(
+ rawText => {
+ const normalized = normalizeSpokenText(rawText, {
+ members: membersRef.current,
+ userLabels: userLabelsRef.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) {
+ const dropped = base[base.length - 1]
+ base = base.slice(0, -1)
+ if (activeHoldSegmentIdRef.current === dropped.id) {
+ activeHoldSegmentIdRef.current = null
+ }
+ haptic('medium')
+ }
+ if (pieces.length === 0) {
+ applySegments(base)
+ return
+ }
+ haptic('light')
+
+ if (!lockedRef.current) {
+ // Hold-to-talk: the first piece continues the entry already active
+ // for this hold (if any); only a spoken separator within the same
+ // commit starts additional new entries.
+ const activeIndex = base.findIndex(
+ s => s.id === activeHoldSegmentIdRef.current,
+ )
+ if (activeIndex !== -1) {
+ const merged = [...base]
+ merged[activeIndex] = {
+ ...merged[activeIndex],
+ text: `${merged[activeIndex].text} ${pieces[0]}`.trim(),
+ }
+ const rest = pieces.slice(1).map(piece => ({
+ id: generateUUID(),
+ text: piece,
+ }))
+ if (rest.length > 0) {
+ activeHoldSegmentIdRef.current = rest[rest.length - 1].id
+ }
+ applySegments([...merged, ...rest])
+ return
+ }
+ }
+
+ const newPieces = pieces.map(piece => ({
+ id: generateUUID(),
+ text: piece,
+ }))
+ if (!lockedRef.current) {
+ activeHoldSegmentIdRef.current = newPieces[newPieces.length - 1].id
+ }
+ applySegments([...base, ...newPieces])
+ },
+ [applySegments],
+ )
+
+ const stopListening = useCallback(async () => {
+ if (watchdogRef.current) {
+ clearInterval(watchdogRef.current)
+ watchdogRef.current = null
+ }
+ await voiceInputService.stop()
+ setPartialText('')
+ setIsLocked(false)
+ // Release ends the current hold — the next hold-press starts a fresh
+ // entry rather than continuing to merge onto this one
+ activeHoldSegmentIdRef.current = null
+ // 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,
+ userLabels: userLabelsRef.current,
+ }),
+ )
+ },
+ onSegment: commitSegment,
+ onError: () => {
+ setPhase('denied')
+ },
+ onStateChange: () => {},
+ },
+ vocabularyRef.current,
+ )
+ 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])
+
+ // One-tap entry: start listening already locked into hands-free mode
+ const startHandsFree = useCallback(async () => {
+ if (phaseRef.current === 'listening') return
+ const ok = await startListening()
+ if (ok) setIsLocked(true)
+ }, [startListening])
+
+ 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; from here on, silence boundaries
+ // should start new entries again, not merge onto the last one
+ activeHoldSegmentIdRef.current = null
+ 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],
+ )
+
+ // Picker edits on a card are stored as overrides that win over whatever a
+ // re-parse of the spoken text would produce
+ const patchSegment = useCallback(
+ (id, patch) => {
+ applySegments(
+ segmentsRef.current.map(s =>
+ s.id === id
+ ? { ...s, overrides: { ...(s.overrides || {}), ...patch } }
+ : s,
+ ),
+ )
+ },
+ [applySegments],
+ )
+
+ const reset = useCallback(() => {
+ voiceInputService.stop()
+ if (watchdogRef.current) {
+ clearInterval(watchdogRef.current)
+ watchdogRef.current = null
+ }
+ applySegments([])
+ setPartialText('')
+ setIsLocked(false)
+ activeHoldSegmentIdRef.current = null
+ 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,
+ startHandsFree,
+ stopListening,
+ removeSegment,
+ updateSegment,
+ patchSegment,
+ 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..17c3a44
--- /dev/null
+++ b/src/views/components/VoiceToTask/voiceNormalizer.js
@@ -0,0 +1,252 @@
+// Deterministic transforms that turn spoken language into the typed syntax
+// CustomParsers understands. No LLM — instant, predictable, fully offline.
+//
+// "label groceries" → "#groceries" (only when it matches an existing label)
+// "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`,
+ )
+
+// "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah".
+// Speech engines spell names their own way — and worse, can auto-correct an
+// unfamiliar name to an unrelated dictionary word entirely ("Moutaz" heard as
+// "Models"), which plain edit-distance can't recover (too many edits apart).
+// But right after an assign verb, the next word has essentially no other
+// legitimate reading — it IS a name — so we take the *relative best* match
+// among circle members rather than requiring it to be objectively close.
+// A same-first-letter guard keeps this from firing on totally unrelated
+// words. Contextual-string biasing in VoiceInputService is the primary
+// defense (it can make the recognizer hear "Moutaz" correctly in the first
+// place); this is the fallback for when biasing isn't supported or still
+// mishears.
+const ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?|for)'
+const STRICT_ASSIGN_VERB = '(?:assign(?:ed|ee)?(?:\\s+(?:this|it))?(?:\\s+to)?)'
+const MIN_MATCH_SCORE = 0.2
+
+const levenshtein = (a, b) => {
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
+ for (let i = 1; i <= a.length; i++) {
+ let diag = prev[0]
+ prev[0] = i
+ for (let j = 1; j <= b.length; j++) {
+ const tmp = prev[j]
+ prev[j] = Math.min(
+ prev[j] + 1,
+ prev[j - 1] + 1,
+ diag + (a[i - 1] === b[j - 1] ? 0 : 1),
+ )
+ diag = tmp
+ }
+ }
+ return prev[b.length]
+}
+
+const similarity = (a, b) =>
+ 1 - levenshtein(a, b) / Math.max(a.length, b.length)
+
+const memberNameVariants = member =>
+ [
+ member.displayName,
+ member.displayName?.split(/\s+/)[0],
+ member.username,
+ ].filter(n => n && n.length > 1)
+
+// Best-scoring item for `candidate` among `items`, requiring only that it
+// beats all others and shares a first letter — not an absolute closeness
+// threshold. `getVariants` returns the name strings to compare a given item
+// against (e.g. a member's display name/first name/username, or a label's
+// name). Shared by assignee and label matching since both face the same
+// problem: ASR is least confident on exactly the words that matter here.
+const findBestFuzzyMatch = (candidate, items, getVariants) => {
+ const c = candidate.toLowerCase()
+ if (c.length < 3) return null
+ let best = null
+ let bestScore = MIN_MATCH_SCORE
+ for (const item of items) {
+ for (const name of getVariants(item)) {
+ const n = name.toLowerCase()
+ if (n === c) return item
+ if (n.length < 3 || n[0] !== c[0]) continue
+ const score = similarity(n, c)
+ if (score > bestScore) {
+ bestScore = score
+ best = item
+ }
+ }
+ }
+ return best
+}
+
+const findMemberFuzzy = (candidate, members) =>
+ findBestFuzzyMatch(candidate, members, memberNameVariants)
+
+// "label groceries" / "tag groceries" / "labeled as groceries" — only ever
+// converts to a label that already exists (matched exactly or as the closest
+// existing one), never invents a new one. Restricted to single-word label
+// names: CustomParsers' hashtag pattern (#([\p{L}\p{N}_]+)) can't span a
+// space, so a multi-word label like "Home Maintenance" could never be
+// represented as "#Home Maintenance" anyway — same limitation typing it by
+// hand would hit.
+const LABEL_VERB =
+ '(?:with\\s+)?(?:hash\\s?tag|labell?ed(?:\\s+as)?|label|tagged(?:\\s+as)?|tag)'
+
+const normalizeLabels = (text, userLabels = []) => {
+ const singleWordLabels = userLabels.filter(l => l.name && !/\s/.test(l.name))
+ let out = text
+
+ // Exact pass first so a clean spoken match always wins over the fuzzy pass
+ const byLengthDesc = [...singleWordLabels].sort(
+ (a, b) => b.name.length - a.name.length,
+ )
+ for (const label of byLengthDesc) {
+ out = out.replace(
+ new RegExp(
+ `\\b${LABEL_VERB}\\s+${escapeRegex(label.name)}\\b[,.]?`,
+ 'gi',
+ ),
+ `#${label.name}`,
+ )
+ }
+
+ // Fuzzy pass — the spoken word after the verb, matched against the closest
+ // existing single-word label
+ out = out.replace(
+ new RegExp(`\\b${LABEL_VERB}\\s+([\\p{L}][\\p{L}'-]*)[,.]?`, 'giu'),
+ (match, candidate) => {
+ const label = findBestFuzzyMatch(candidate, singleWordLabels, l => [
+ l.name,
+ ])
+ return label ? `#${label.name}` : match
+ },
+ )
+ return out
+}
+
+const normalizeAssignees = (text, members = []) => {
+ let out = text.replace(
+ new RegExp(
+ `\\b${ASSIGN_VERB}\\s+(?:anyone|anybody|everyone)\\b[,.]?`,
+ 'gi',
+ ),
+ '@Anyone',
+ )
+
+ // Exact pass — full display name first so "assign to Mo Tarbin" doesn't
+ // leave a dangling "Tarbin"
+ for (const member of members) {
+ if (!member.displayName) continue
+ const names = [...new Set(memberNameVariants(member))].sort(
+ (a, b) => b.length - a.length,
+ )
+ for (const name of names) {
+ out = out.replace(
+ new RegExp(`\\b${ASSIGN_VERB}\\s+${escapeRegex(name)}\\b[,.]?`, 'gi'),
+ `@${member.displayName}`,
+ )
+ }
+ }
+
+ // Fuzzy pass — requires an assign verb (not bare "for") so only clearly
+ // intended names get corrected
+ out = out.replace(
+ new RegExp(`\\b${STRICT_ASSIGN_VERB}\\s+([\\p{L}][\\p{L}'-]*)[,.]?`, 'giu'),
+ (match, candidate) => {
+ const member = findMemberFuzzy(candidate, members)
+ return member ? `@${member.displayName}` : match
+ },
+ )
+ return out
+}
+
+export const normalizeSpokenText = (
+ text,
+ { members = [], userLabels = [] } = {},
+) => {
+ let out = stripFillers(text)
+ out = normalizePriority(out)
+ out = normalizePoints(out)
+ out = normalizeLabels(out, userLabels)
+ 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 }
+}