diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index bda067c..63ec27f 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -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) {
diff --git a/src/views/components/VoiceToTask/VoicePanel.jsx b/src/views/components/VoiceToTask/VoicePanel.jsx
index f271fa6..b33391a 100644
--- a/src/views/components/VoiceToTask/VoicePanel.jsx
+++ b/src/views/components/VoiceToTask/VoicePanel.jsx
@@ -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: ,
- label: formatDue(parsed.dueDate),
+ label: formatDue(effective.dueDate),
})
}
- if (parsed.frequencyName) {
+ if (frequencyLabel) {
chips.push({
key: 'repeat',
color: 'success',
icon: ,
- label: parsed.frequencyName,
+ label: frequencyLabel,
})
}
- if (parsed.priority > 0) {
+ if (effective.priority > 0) {
chips.push({
key: 'priority',
color: 'danger',
icon: ,
- label: `P${parsed.priority}`,
+ label: `P${effective.priority}`,
})
}
- if (parsed.points != null) {
+ if (effective.points != null) {
chips.push({
key: 'points',
color: 'primary',
icon: ,
- 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: ,
+ 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 }) => {
}}
>
- {editing ? (
+ {expanded ? (
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 }}
/>
) : (
{
setDraft(segment.text)
- setEditing(true)
+ setExpanded(true)
}}
>
{parsed.title || segment.text}
)}
+ {expanded && (
+ {
+ commitText()
+ setExpanded(false)
+ }}
+ sx={{ '--IconButton-size': '28px' }}
+ >
+
+
+ )}
{
- {chips.length > 0 && (
-
+
+ {!expanded && chips.length > 0 && (
+ {
+ setDraft(segment.text)
+ setExpanded(true)
+ }}
+ >
{chips.map(chip => (
{
))}
)}
+
+ {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}
+ />
+
+ )}
)
}
@@ -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
@@ -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)}
/>
))}
@@ -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
diff --git a/src/views/components/VoiceToTask/parseVoiceTask.js b/src/views/components/VoiceToTask/parseVoiceTask.js
index ad46871..7b889ee 100644
--- a/src/views/components/VoiceToTask/parseVoiceTask.js
+++ b/src/views/components/VoiceToTask/parseVoiceTask.js
@@ -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,
diff --git a/src/views/components/VoiceToTask/useVoiceToTask.js b/src/views/components/VoiceToTask/useVoiceToTask.js
index 45cf0fe..6739a33 100644
--- a/src/views/components/VoiceToTask/useVoiceToTask.js
+++ b/src/views/components/VoiceToTask/useVoiceToTask.js
@@ -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,
}
diff --git a/src/views/components/VoiceToTask/voiceNormalizer.js b/src/views/components/VoiceToTask/voiceNormalizer.js
index 1fffc8d..a7cef88 100644
--- a/src/views/components/VoiceToTask/voiceNormalizer.js
+++ b/src/views/components/VoiceToTask/voiceNormalizer.js
@@ -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
}