ability to parse user and label
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user