ability to parse user and label
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -25,6 +26,7 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
@@ -69,6 +71,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
useCircleMembers()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
@@ -92,6 +95,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
const richTextEditorRef = useRef(null)
|
||||
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 [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
@@ -492,6 +498,28 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
@@ -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
|
||||
// reviews it with the normal pickers before creating.
|
||||
const handleVoiceSingle = text => {
|
||||
// reviews it with the normal pickers before creating. Setting taskText
|
||||
// (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)
|
||||
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,
|
||||
// so create them all directly.
|
||||
const handleVoiceCreateMany = async parsedTasks => {
|
||||
const notificationTemplates = getDefaultNotification()
|
||||
const allNewLabels = parsedTasks.flatMap(t => t.newLabels || [])
|
||||
const labelIdsByName =
|
||||
allNewLabels.length > 0 ? await createMissingLabels(allNewLabels) : null
|
||||
for (const parsed of parsedTasks) {
|
||||
const chore = buildChorePayload(parsed, {
|
||||
userProfile,
|
||||
projectId,
|
||||
notificationTemplates,
|
||||
})
|
||||
const extraLabelIds = (parsed.newLabels || [])
|
||||
.map(nl => labelIdsByName?.get(nl.name.toLowerCase()))
|
||||
.filter(id => id != null && !parsed.labelIds.includes(id))
|
||||
const chore = buildChorePayload(
|
||||
{ ...parsed, labelIds: [...parsed.labelIds, ...extraLabelIds] },
|
||||
{
|
||||
userProfile,
|
||||
projectId,
|
||||
notificationTemplates,
|
||||
},
|
||||
)
|
||||
try {
|
||||
const result = await createChoreMutation.mutateAsync(chore)
|
||||
if (result?._pendingCreate) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CalendarMonth,
|
||||
Check,
|
||||
Close,
|
||||
Flag,
|
||||
GraphicEq,
|
||||
@@ -13,7 +14,13 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
|
||||
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 { useVoiceToTask } from './useVoiceToTask'
|
||||
import './VoicePanel.css'
|
||||
@@ -27,6 +34,22 @@ const HIGHLIGHT_CLASS = {
|
||||
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
|
||||
@@ -58,41 +81,62 @@ const formatDue = dueDate => {
|
||||
: 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 = []
|
||||
if (parsed.dueDate) {
|
||||
if (effective.dueDate) {
|
||||
chips.push({
|
||||
key: 'due',
|
||||
color: 'warning',
|
||||
icon: <CalendarMonth sx={{ fontSize: 12 }} />,
|
||||
label: formatDue(parsed.dueDate),
|
||||
label: formatDue(effective.dueDate),
|
||||
})
|
||||
}
|
||||
if (parsed.frequencyName) {
|
||||
if (frequencyLabel) {
|
||||
chips.push({
|
||||
key: 'repeat',
|
||||
color: 'success',
|
||||
icon: <Repeat sx={{ fontSize: 12 }} />,
|
||||
label: parsed.frequencyName,
|
||||
label: frequencyLabel,
|
||||
})
|
||||
}
|
||||
if (parsed.priority > 0) {
|
||||
if (effective.priority > 0) {
|
||||
chips.push({
|
||||
key: 'priority',
|
||||
color: 'danger',
|
||||
icon: <Flag sx={{ fontSize: 12 }} />,
|
||||
label: `P${parsed.priority}`,
|
||||
label: `P${effective.priority}`,
|
||||
})
|
||||
}
|
||||
if (parsed.points != null) {
|
||||
if (effective.points != null) {
|
||||
chips.push({
|
||||
key: 'points',
|
||||
color: 'primary',
|
||||
icon: <Toll sx={{ fontSize: 12 }} />,
|
||||
label: `${parsed.points} pts`,
|
||||
label: `${effective.points} pts`,
|
||||
})
|
||||
}
|
||||
parsed.labelNames.forEach(name => {
|
||||
effective.labelNames.forEach(name => {
|
||||
chips.push({
|
||||
key: `label-${name}`,
|
||||
color: 'primary',
|
||||
@@ -100,7 +144,15 @@ const buildChips = (parsed, { members, currentUserId }) => {
|
||||
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({
|
||||
key: 'assignee',
|
||||
color: 'neutral',
|
||||
@@ -108,10 +160,10 @@ const buildChips = (parsed, { members, currentUserId }) => {
|
||||
label: 'Anyone',
|
||||
})
|
||||
} else if (
|
||||
parsed.assignees.length > 0 &&
|
||||
parsed.assignees[0].userId !== currentUserId
|
||||
effective.assignees.length > 0 &&
|
||||
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) {
|
||||
chips.push({
|
||||
key: 'assignee',
|
||||
@@ -124,18 +176,69 @@ const buildChips = (parsed, { members, currentUserId }) => {
|
||||
return chips
|
||||
}
|
||||
|
||||
const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
const [editing, setEditing] = useState(false)
|
||||
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 chips = useMemo(() => buildChips(parsed, parseCtx), [parsed, parseCtx])
|
||||
const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
|
||||
const effective = useMemo(
|
||||
() => ({ ...parsed, ...overrides }),
|
||||
[parsed, overrides],
|
||||
)
|
||||
|
||||
const commitEdit = () => {
|
||||
setEditing(false)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -145,7 +248,7 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderColor: expanded ? 'primary.outlinedBorder' : 'divider',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1.25,
|
||||
display: 'flex',
|
||||
@@ -154,34 +257,45 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{editing ? (
|
||||
{expanded ? (
|
||||
<Input
|
||||
size='sm'
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitEdit()
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(segment.text)
|
||||
setEditing(false)
|
||||
}
|
||||
if (e.key === 'Enter') commitText()
|
||||
if (e.key === 'Escape') setDraft(segment.text)
|
||||
}}
|
||||
onBlur={commitEdit}
|
||||
onBlur={commitText}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ flex: 1, cursor: 'text', wordBreak: 'break-word' }}
|
||||
sx={{ flex: 1, cursor: 'pointer', wordBreak: 'break-word' }}
|
||||
onClick={() => {
|
||||
setDraft(segment.text)
|
||||
setEditing(true)
|
||||
setExpanded(true)
|
||||
}}
|
||||
>
|
||||
{parsed.title || segment.text}
|
||||
</Typography>
|
||||
)}
|
||||
{expanded && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
commitText()
|
||||
setExpanded(false)
|
||||
}}
|
||||
sx={{ '--IconButton-size': '28px' }}
|
||||
>
|
||||
<Check fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='plain'
|
||||
@@ -192,8 +306,20 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
</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 => (
|
||||
<Chip
|
||||
key={chip.key}
|
||||
@@ -207,6 +333,75 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -214,10 +409,10 @@ const TaskPreviewCard = ({ segment, parseCtx, onRemove, onUpdate }) => {
|
||||
/**
|
||||
* 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.
|
||||
* 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,
|
||||
@@ -235,13 +430,15 @@ const VoicePanel = ({
|
||||
segments,
|
||||
micPressDown,
|
||||
micPressUp,
|
||||
startListening,
|
||||
startHandsFree,
|
||||
removeSegment,
|
||||
updateSegment,
|
||||
patchSegment,
|
||||
reset,
|
||||
isNative,
|
||||
} = useVoiceToTask({ members })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const autoStartedRef = useRef(false)
|
||||
|
||||
const parseCtx = useMemo(
|
||||
() => ({ userLabels, members, currentUserId: userProfile?.id }),
|
||||
@@ -253,6 +450,15 @@ const VoicePanel = ({
|
||||
[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
|
||||
|
||||
const isListening = phase === 'listening'
|
||||
@@ -263,10 +469,15 @@ const VoicePanel = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const mergedTask = segment => ({
|
||||
...parseVoiceTask(segment.text, parseCtx),
|
||||
...(segment.overrides || {}),
|
||||
})
|
||||
|
||||
const handleCreateAll = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreateMany(segments.map(s => parseVoiceTask(s.text, parseCtx)))
|
||||
await onCreateMany(segments.map(mergedTask))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -331,7 +542,7 @@ const VoicePanel = ({
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={startListening}
|
||||
onClick={startHandsFree}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
@@ -347,7 +558,7 @@ const VoicePanel = ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
maxHeight: 260,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
@@ -358,6 +569,7 @@ const VoicePanel = ({
|
||||
parseCtx={parseCtx}
|
||||
onRemove={() => removeSegment(segment.id)}
|
||||
onUpdate={text => updateSegment(segment.id, text)}
|
||||
onPatch={patch => patchSegment(segment.id, patch)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -470,7 +682,9 @@ const VoicePanel = ({
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => onUseSingle(segments[0].text)}
|
||||
onClick={() =>
|
||||
onUseSingle(segments[0].text, segments[0].overrides || {})
|
||||
}
|
||||
>
|
||||
Use Task
|
||||
</Button>
|
||||
|
||||
@@ -124,6 +124,7 @@ export const parseVoiceTask = (
|
||||
points: points.result ?? null,
|
||||
labelIds,
|
||||
labelNames: (labels.result || []).map(label => label.name),
|
||||
newLabels: labels.newLabels || [],
|
||||
assignees,
|
||||
isAnyone,
|
||||
frequency: repeat.result,
|
||||
|
||||
@@ -135,6 +135,13 @@ export function useVoiceToTask({ members = [] } = {}) {
|
||||
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') {
|
||||
@@ -180,6 +187,21 @@ export function useVoiceToTask({ members = [] } = {}) {
|
||||
[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) {
|
||||
@@ -208,9 +230,11 @@ export function useVoiceToTask({ members = [] } = {}) {
|
||||
micPressDown,
|
||||
micPressUp,
|
||||
startListening,
|
||||
startHandsFree,
|
||||
stopListening,
|
||||
removeSegment,
|
||||
updateSegment,
|
||||
patchSegment,
|
||||
reset,
|
||||
isNative: voiceInputService.isNative,
|
||||
}
|
||||
|
||||
@@ -61,28 +61,88 @@ const normalizeLabels = text =>
|
||||
'#$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 assignVerb = '(?:assign(?:ed)?\\s+(?:this\\s+|it\\s+)?to|for)'
|
||||
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',
|
||||
)
|
||||
|
||||
// Exact pass — full display name first so "assign to Mo Tarbin" doesn't
|
||||
// leave a dangling "Tarbin"
|
||||
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,
|
||||
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${assignVerb}\\s+${escapeRegex(name)}\\b`, 'gi'),
|
||||
`@${displayName}`,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user