ability to parse user and label

This commit is contained in:
Mo Tarbin
2026-07-20 01:54:39 -04:00
parent e0257445ef
commit a133b271d0
5 changed files with 436 additions and 59 deletions

View File

@@ -1,6 +1,7 @@
import { Add } from '@mui/icons-material' import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy' import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import moment from 'moment' import moment from 'moment'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@@ -25,6 +26,7 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService' import { localAIService } from '../../service/LocalAIService'
import { voiceInputService } from '../../service/VoiceInputService' import { voiceInputService } from '../../service/VoiceInputService'
import { CreateLabel } from '../../utils/Fetcher'
import { TASK_COLOR } from '../../utils/Colors' import { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, { import AdvancedOptionsSection, {
AdvancedOptionsTrigger, AdvancedOptionsTrigger,
@@ -69,6 +71,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
useCircleMembers() useCircleMembers()
const { isLoading: isProjectsLoading } = useProjects() const { isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore() const createChoreMutation = useCreateChore()
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
@@ -92,6 +95,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const richTextEditorRef = useRef(null) const richTextEditorRef = useRef(null)
const latestRef = useRef({}) const latestRef = useRef({})
// Picker edits made on a voice task card, applied once after the reparse
// that follows landing the spoken text in the smart input
const pendingVoiceOverridesRef = useRef(null)
const [priority, setPriority] = useState(0) const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null) const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null) const [description, setDescription] = useState(null)
@@ -492,6 +498,28 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
) )
setRenderedParts(parts) setRenderedParts(parts)
const overrides = pendingVoiceOverridesRef.current
if (overrides) {
pendingVoiceOverridesRef.current = null
if ('priority' in overrides) setPriority(overrides.priority || 0)
if ('frequency' in overrides) setFrequency(overrides.frequency)
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
}
if ('dueDate' in overrides) {
if (overrides.dueDate) {
syncDueDateStates(overrides.dueDate)
} else {
setDueDate(null)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
}
}
}
}, },
[userLabels, renderHighlightedSentence, circleMembers, userProfile], [userLabels, renderHighlightedSentence, circleMembers, userProfile],
) )
@@ -595,23 +623,73 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
} }
} }
// Creates labels that were spoken but don't exist yet. Returns a Map of
// lowercase name → label id covering both created and already-existing ones.
const createMissingLabels = async newLabels => {
const resolved = new Map(
(userLabels || []).map(l => [l.name.toLowerCase(), l.id]),
)
let createdAny = false
for (const label of newLabels) {
const key = label.name.toLowerCase()
if (resolved.has(key)) continue
try {
const resp = await CreateLabel({
name: label.name,
color: label.color || '#3b82f6',
})
const data = await resp.json()
const created = data?.res ?? data
if (created?.id) {
resolved.set(key, created.id)
createdAny = true
}
} catch (error) {
console.error('Error creating label:', error)
}
}
if (createdAny) {
queryClient.invalidateQueries({ queryKey: ['labels'] })
}
return resolved
}
// Single voice-captured task: land it in the smart input so the user // Single voice-captured task: land it in the smart input so the user
// reviews it with the normal pickers before creating. // reviews it with the normal pickers before creating. Setting taskText
const handleVoiceSingle = text => { // (rather than calling processText directly) lets the reparse effect run
// exactly once, consuming any picker overrides from the panel.
const handleVoiceSingle = async (text, overrides = {}) => {
setShowVoice(false) setShowVoice(false)
processText(text) if (Object.keys(overrides).length > 0) {
pendingVoiceOverridesRef.current = overrides
}
setTaskText(text)
const labels = parseLabels(text, userLabels || [])
if (labels.newLabels?.length) {
// Once the labels query refetches, the reparse links them automatically
await createMissingLabels(labels.newLabels)
}
} }
// Multiple voice-captured tasks: they were reviewed as cards in the panel, // Multiple voice-captured tasks: they were reviewed as cards in the panel,
// so create them all directly. // so create them all directly.
const handleVoiceCreateMany = async parsedTasks => { const handleVoiceCreateMany = async parsedTasks => {
const notificationTemplates = getDefaultNotification() const notificationTemplates = getDefaultNotification()
const allNewLabels = parsedTasks.flatMap(t => t.newLabels || [])
const labelIdsByName =
allNewLabels.length > 0 ? await createMissingLabels(allNewLabels) : null
for (const parsed of parsedTasks) { for (const parsed of parsedTasks) {
const chore = buildChorePayload(parsed, { const extraLabelIds = (parsed.newLabels || [])
userProfile, .map(nl => labelIdsByName?.get(nl.name.toLowerCase()))
projectId, .filter(id => id != null && !parsed.labelIds.includes(id))
notificationTemplates, const chore = buildChorePayload(
}) { ...parsed, labelIds: [...parsed.labelIds, ...extraLabelIds] },
{
userProfile,
projectId,
notificationTemplates,
},
)
try { try {
const result = await createChoreMutation.mutateAsync(chore) const result = await createChoreMutation.mutateAsync(chore)
if (result?._pendingCreate) { if (result?._pendingCreate) {

View File

@@ -1,5 +1,6 @@
import { import {
CalendarMonth, CalendarMonth,
Check,
Close, Close,
Flag, Flag,
GraphicEq, GraphicEq,
@@ -13,7 +14,13 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy' import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useMemo, useState } from 'react' 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 { parseVoiceTask } from './parseVoiceTask'
import { useVoiceToTask } from './useVoiceToTask' import { useVoiceToTask } from './useVoiceToTask'
import './VoicePanel.css' import './VoicePanel.css'
@@ -27,6 +34,22 @@ const HIGHLIGHT_CLASS = {
dueDate: 'highlight-date', 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 renderTranscript = (text, highlights) => {
const parts = [] const parts = []
let lastIndex = 0 let lastIndex = 0
@@ -58,41 +81,62 @@ const formatDue = dueDate => {
: m.format('MMM D, h:mm A') : m.format('MMM D, h:mm A')
} }
const buildChips = (parsed, { members, currentUserId }) => { // 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 = [] const chips = []
if (parsed.dueDate) { if (effective.dueDate) {
chips.push({ chips.push({
key: 'due', key: 'due',
color: 'warning', color: 'warning',
icon: <CalendarMonth sx={{ fontSize: 12 }} />, icon: <CalendarMonth sx={{ fontSize: 12 }} />,
label: formatDue(parsed.dueDate), label: formatDue(effective.dueDate),
}) })
} }
if (parsed.frequencyName) { if (frequencyLabel) {
chips.push({ chips.push({
key: 'repeat', key: 'repeat',
color: 'success', color: 'success',
icon: <Repeat sx={{ fontSize: 12 }} />, icon: <Repeat sx={{ fontSize: 12 }} />,
label: parsed.frequencyName, label: frequencyLabel,
}) })
} }
if (parsed.priority > 0) { if (effective.priority > 0) {
chips.push({ chips.push({
key: 'priority', key: 'priority',
color: 'danger', color: 'danger',
icon: <Flag sx={{ fontSize: 12 }} />, icon: <Flag sx={{ fontSize: 12 }} />,
label: `P${parsed.priority}`, label: `P${effective.priority}`,
}) })
} }
if (parsed.points != null) { if (effective.points != null) {
chips.push({ chips.push({
key: 'points', key: 'points',
color: 'primary', color: 'primary',
icon: <Toll sx={{ fontSize: 12 }} />, icon: <Toll sx={{ fontSize: 12 }} />,
label: `${parsed.points} pts`, label: `${effective.points} pts`,
}) })
} }
parsed.labelNames.forEach(name => { effective.labelNames.forEach(name => {
chips.push({ chips.push({
key: `label-${name}`, key: `label-${name}`,
color: 'primary', color: 'primary',
@@ -100,7 +144,15 @@ const buildChips = (parsed, { members, currentUserId }) => {
label: name, label: name,
}) })
}) })
if (parsed.isAnyone) { effective.newLabels.forEach(label => {
chips.push({
key: `new-label-${label.name}`,
color: 'warning',
icon: <Sell sx={{ fontSize: 12 }} />,
label: `${label.name} · new`,
})
})
if (effective.isAnyone) {
chips.push({ chips.push({
key: 'assignee', key: 'assignee',
color: 'neutral', color: 'neutral',
@@ -108,10 +160,10 @@ const buildChips = (parsed, { members, currentUserId }) => {
label: 'Anyone', label: 'Anyone',
}) })
} else if ( } else if (
parsed.assignees.length > 0 && effective.assignees.length > 0 &&
parsed.assignees[0].userId !== currentUserId effective.assignees[0].userId !== currentUserId
) { ) {
const member = members.find(m => m.userId === parsed.assignees[0].userId) const member = members.find(m => m.userId === effective.assignees[0].userId)
if (member) { if (member) {
chips.push({ chips.push({
key: 'assignee', key: 'assignee',
@@ -124,18 +176,69 @@ const buildChips = (parsed, { members, currentUserId }) => {
return chips return chips
} }
const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => { const TaskPreviewCard = ({
const [editing, setEditing] = useState(false) segment,
parseCtx,
onRemove,
onUpdate,
onPatch,
}) => {
const [expanded, setExpanded] = useState(false)
const [draft, setDraft] = useState(segment.text) const [draft, setDraft] = useState(segment.text)
const dueEditRef = useRef(null)
const parsed = useMemo( const parsed = useMemo(
() => parseVoiceTask(segment.text, parseCtx), () => parseVoiceTask(segment.text, parseCtx),
[segment.text, parseCtx], [segment.text, parseCtx],
) )
const chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx]) const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
const effective = useMemo(
() => ({ ...parsed, ...overrides }),
[parsed, overrides],
)
const commitEdit = () => { const frequencyLabel =
setEditing(false) '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) if (draft.trim() !== segment.text) onUpdate(draft)
} }
@@ -145,7 +248,7 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
sx={{ sx={{
borderRadius: 'md', borderRadius: 'md',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: expanded ? 'primary.outlinedBorder' : 'divider',
bgcolor: 'background.surface', bgcolor: 'background.surface',
p: 1.25, p: 1.25,
display: 'flex', display: 'flex',
@@ -154,34 +257,45 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{editing ? ( {expanded ? (
<Input <Input
size='sm' size='sm'
autoFocus autoFocus
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => setDraft(e.target.value)}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter') commitEdit() if (e.key === 'Enter') commitText()
if (e.key === 'Escape') { if (e.key === 'Escape') setDraft(segment.text)
setDraft(segment.text)
setEditing(false)
}
}} }}
onBlur={commitEdit} onBlur={commitText}
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
) : ( ) : (
<Typography <Typography
level='title-sm' level='title-sm'
sx={{ flex: 1, cursor: 'text', wordBreak: 'break-word' }} sx={{ flex: 1, cursor: 'pointer', wordBreak: 'break-word' }}
onClick={() => { onClick={() => {
setDraft(segment.text) setDraft(segment.text)
setEditing(true) setExpanded(true)
}} }}
> >
{parsed.title || segment.text} {parsed.title || segment.text}
</Typography> </Typography>
)} )}
{expanded && (
<IconButton
size='sm'
variant='soft'
color='primary'
onClick={() => {
commitText()
setExpanded(false)
}}
sx={{ '--IconButton-size': '28px' }}
>
<Check fontSize='small' />
</IconButton>
)}
<IconButton <IconButton
size='sm' size='sm'
variant='plain' variant='plain'
@@ -192,8 +306,20 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
<Close fontSize='small' /> <Close fontSize='small' />
</IconButton> </IconButton>
</Box> </Box>
{chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> {!expanded && chips.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
cursor: 'pointer',
}}
onClick={() => {
setDraft(segment.text)
setExpanded(true)
}}
>
{chips.map(chip => ( {chips.map(chip => (
<Chip <Chip
key={chip.key} key={chip.key}
@@ -207,6 +333,75 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
))} ))}
</Box> </Box>
)} )}
{expanded && (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
overflowX: 'auto',
pt: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
}}
>
<DueDatePickerField
emptyDisplay='icon'
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={hasCustomTime}
onDueDateChange={e =>
queueDuePatch({ date: e.target.value || null })
}
onDueTimeChange={e =>
queueDuePatch({ time: e.target.value || null })
}
onUseCustomTimeChange={checked =>
queueDuePatch({ custom: checked })
}
onClear={() => onPatch({ dueDate: null })}
/>
<RepeatPickerField
emptyDisplay='icon'
value={effective.frequency}
onChange={f => onPatch({ frequency: f })}
onClear={() => onPatch({ frequency: null })}
/>
<PriorityPickerField
emptyDisplay='icon'
value={effective.priority}
onChange={p => onPatch({ priority: p })}
onClear={() => onPatch({ priority: 0 })}
priorityColors={PRIORITY_COLORS}
priorityLabels={PRIORITY_LABELS}
/>
<AssigneePickerField
emptyDisplay='icon'
values={effective.assignees.map(a => 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}
/>
<LabelsPickerField
emptyDisplay='icon'
values={effective.labelIds}
onChange={ids => onPatch({ labelIds: ids })}
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
</Box>
)}
</Box> </Box>
) )
} }
@@ -214,10 +409,10 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
/** /**
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. * 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 * Opens straight into hands-free listening. Pauses and spoken separators
* separators ("also") split the transcript into task cards. A single captured * ("also") split the transcript into task cards; tapping a card opens inline
* task lands in the smart input for review; multiple tasks are created * pickers whose edits override the parsed values. A single captured task
* directly from the review list. * lands in the smart input for review; multiple are created directly.
*/ */
const VoicePanel = ({ const VoicePanel = ({
open, open,
@@ -235,13 +430,15 @@ const VoicePanel = ({
segments, segments,
micPressDown, micPressDown,
micPressUp, micPressUp,
startListening, startHandsFree,
removeSegment, removeSegment,
updateSegment, updateSegment,
patchSegment,
reset, reset,
isNative, isNative,
} = useVoiceToTask({ members }) } = useVoiceToTask({ members })
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const autoStartedRef = useRef(false)
const parseCtx = useMemo( const parseCtx = useMemo(
() => ({ userLabels, members, currentUserId: userProfile?.id }), () => ({ userLabels, members, currentUserId: userProfile?.id }),
@@ -253,6 +450,15 @@ const VoicePanel = ({
[partialText, parseCtx], [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])
if (!open) return null if (!open) return null
const isListening = phase === 'listening' const isListening = phase === 'listening'
@@ -263,10 +469,15 @@ const VoicePanel = ({
onClose() onClose()
} }
const mergedTask = segment => ({
...parseVoiceTask(segment.text, parseCtx),
...(segment.overrides || {}),
})
const handleCreateAll = async () => { const handleCreateAll = async () => {
setCreating(true) setCreating(true)
try { try {
await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx))) await onCreateMany(segments.map(mergedTask))
} finally { } finally {
setCreating(false) setCreating(false)
} }
@@ -331,7 +542,7 @@ const VoicePanel = ({
size='sm' size='sm'
variant='outlined' variant='outlined'
color='neutral' color='neutral'
onClick={startListening} onClick={startHandsFree}
> >
Try Again Try Again
</Button> </Button>
@@ -347,7 +558,7 @@ const VoicePanel = ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 0.75, gap: 0.75,
maxHeight: 260, maxHeight: 300,
overflowY: 'auto', overflowY: 'auto',
}} }}
> >
@@ -358,6 +569,7 @@ const VoicePanel = ({
parseCtx={parseCtx} parseCtx={parseCtx}
onRemove={() => removeSegment(segment.id)} onRemove={() => removeSegment(segment.id)}
onUpdate={text => updateSegment(segment.id, text)} onUpdate={text => updateSegment(segment.id, text)}
onPatch={patch => patchSegment(segment.id, patch)}
/> />
))} ))}
</Box> </Box>
@@ -470,7 +682,9 @@ const VoicePanel = ({
size='sm' size='sm'
variant='solid' variant='solid'
color='primary' color='primary'
onClick={() => onUseSingle(segments[0].text)} onClick={() =>
onUseSingle(segments[0].text, segments[0].overrides || {})
}
> >
Use Task Use Task
</Button> </Button>

View File

@@ -124,6 +124,7 @@ export const parseVoiceTask = (
points: points.result ?? null, points: points.result ?? null,
labelIds, labelIds,
labelNames: (labels.result || []).map(label => label.name), labelNames: (labels.result || []).map(label => label.name),
newLabels: labels.newLabels || [],
assignees, assignees,
isAnyone, isAnyone,
frequency: repeat.result, frequency: repeat.result,

View File

@@ -135,6 +135,13 @@ export function useVoiceToTask({ members = [] } = {}) {
return true return true
}, [commitSegment, stopListening]) }, [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(() => { const micPressDown = useCallback(() => {
pressStartedAtRef.current = Date.now() pressStartedAtRef.current = Date.now()
if (phaseRef.current === 'listening') { if (phaseRef.current === 'listening') {
@@ -180,6 +187,21 @@ export function useVoiceToTask({ members = [] } = {}) {
[applySegments], [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(() => { const reset = useCallback(() => {
voiceInputService.stop() voiceInputService.stop()
if (watchdogRef.current) { if (watchdogRef.current) {
@@ -208,9 +230,11 @@ export function useVoiceToTask({ members = [] } = {}) {
micPressDown, micPressDown,
micPressUp, micPressUp,
startListening, startListening,
startHandsFree,
stopListening, stopListening,
removeSegment, removeSegment,
updateSegment, updateSegment,
patchSegment,
reset, reset,
isNative: voiceInputService.isNative, isNative: voiceInputService.isNative,
} }

View File

@@ -61,28 +61,88 @@ const normalizeLabels = text =>
'#$1', '#$1',
) )
// "assign to Sarah" / "assigned to Sarah" / "assign Sarah" / "for Sarah".
// Speech engines spell names their own way ("Sara" for Sarah) and add
// punctuation, so exact display-name matching alone misses real speech —
// an edit-distance-1 fuzzy pass catches those, but only after an explicit
// assign verb so ordinary words never convert.
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 levenshtein = (a, b) => {
if (Math.abs(a.length - b.length) > 1) return 2
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 memberNameVariants = member =>
[
member.displayName,
member.displayName?.split(/\s+/)[0],
member.username,
].filter(n => n && n.length > 1)
const findMemberFuzzy = (candidate, members) => {
const c = candidate.toLowerCase()
let close = null
for (const member of members) {
for (const name of memberNameVariants(member)) {
const n = name.toLowerCase()
if (n === c) return member
if (!close && n.length >= 4 && c.length >= 4 && levenshtein(n, c) <= 1) {
close = member
}
}
}
return close
}
const normalizeAssignees = (text, members = []) => { const normalizeAssignees = (text, members = []) => {
const assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)'
let out = text.replace( let out = text.replace(
new RegExp(`\\b${assignVerb}\\s+(?:anyone|anybody|everyone)\\b`, 'gi'), new RegExp(
`\\b${ASSIGN_VERB}\\s+(?:anyone|anybody|everyone)\\b[,.]?`,
'gi',
),
'@Anyone', '@Anyone',
) )
// Exact pass — full display name first so "assign to Mo Tarbin" doesn't
// leave a dangling "Tarbin"
for (const member of members) { for (const member of members) {
const displayName = member.displayName if (!member.displayName) continue
if (!displayName) continue const names = [...new Set(memberNameVariants(member))].sort(
const firstName = displayName.split(/\s+/)[0] (a, b) => b.length - a.length,
// 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) { for (const name of names) {
out = out.replace( out = out.replace(
new RegExp(`\\b${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'), new RegExp(`\\b${ASSIGN_VERB}\\s+${escapeRegex(name)}\\b[,.]?`, 'gi'),
`@${displayName}`, `@${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 return out
} }