From 2442b0c4412d0b02f4d808db9e7d4ff2bcc2d196 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 9 Aug 2026 13:19:16 -0400 Subject: [PATCH] Fix: autocomplete not visible. refactor: streamline task input handling and improve smart input suggestions and and it's performance --- src/views/components/AddTaskModal.jsx | 274 ++++++++++++++++---------- 1 file changed, 168 insertions(+), 106 deletions(-) diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 930192e..42dc64b 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import ModalActions from '../../components/common/ModalActions' @@ -108,6 +109,66 @@ const getDefaultNotification = () => { return DEFAULT_NOTIFICATION_TEMPLATES } +// Get initial project from localStorage (current active project) +const getInitialProject = () => { + const saved = localStorage.getItem('selectedProject') + if (saved) { + try { + const project = JSON.parse(saved) + return project?.id || 'default' + } catch { + return 'default' + } + } + return 'default' +} + +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', +} + +// Static option sets for the smart input's trigger suggestions +const PRIORITY_SUGGESTIONS = { + value: 'id', + display: 'name', + options: [ + { id: '1', name: 'P1' }, + { id: '2', name: 'P2' }, + { id: '3', name: 'P3' }, + { id: '4', name: 'P4' }, + ], +} + +const POINTS_SUGGESTIONS = { + value: 'id', + display: 'name', + options: [ + { id: '1', name: '1 point' }, + { id: '5', name: '5 points' }, + { id: '10', name: '10 points' }, + { id: '25', name: '25 points' }, + { id: '50', name: '50 points' }, + { id: '100', name: '100 points' }, + ], +} + +// Delay between the last keystroke and the smart-input parse. Parsing (chrono +// especially) is too heavy to run per keystroke; submitChore flushes a pending +// parse so a fast type-then-Enter never creates from stale parsed state. +const PARSE_DEBOUNCE_MS = 150 + const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const { ResponsiveModal } = useResponsiveModal() const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) @@ -138,23 +199,34 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { [queryClient], ) - // Get initial project from localStorage (current active project) - const getInitialProject = () => { - const saved = localStorage.getItem('selectedProject') - if (saved) { - try { - const project = JSON.parse(saved) - return project?.id || 'default' - } catch { - return 'default' - } - } - return 'default' - } + const smartInputSuggestions = useMemo( + () => ({ + '#': { + value: 'id', + display: 'name', + options: userLabels || [], + creatable: true, + onCreate: handleCreateLabel, + }, + '!': PRIORITY_SUGGESTIONS, + '@': { + value: 'userId', + display: 'displayName', + options: [ + { userId: 'anyone', displayName: 'Anyone' }, + ...(circleMembers?.res || []), + ], + }, + '*': POINTS_SUGGESTIONS, + }), + [userLabels, circleMembers, handleCreateLabel], + ) const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') - const [renderedParts, setRenderedParts] = useState([]) + // Highlight spans paired with the text they were computed from: the parse is + // debounced, so the input falls back to plain text whenever these are stale + const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] }) const richTextEditorRef = useRef(null) const latestRef = useRef({}) @@ -165,15 +237,19 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { // reparse without mentions only resets what a mention set — never a // selection made directly in the assignee picker const assigneesFromMentionRef = useRef(false) + // Pending debounced parse of the smart input text, if any + const parseTimerRef = useRef(null) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) - const [notificationMetadata, setNotificationMetadata] = useState({ + // Lazy initializers: these read localStorage, which must not happen on + // every render + const [notificationMetadata, setNotificationMetadata] = useState(() => ({ templates: getDefaultNotification(), - }) + })) const [subTasks, setSubTasks] = useState(null) const [points, setPoints] = useState(-1) const [isAnyoneTask, setIsAnyoneTask] = useState(false) @@ -189,7 +265,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const [dueTime, setDueTime] = useState(null) const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) - const [projectId, setProjectId] = useState(getInitialProject()) + const [projectId, setProjectId] = useState(getInitialProject) const [attachments, setAttachments] = useState([]) const [draftId, setDraftId] = useState(() => generateUUID()) @@ -255,23 +331,6 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } }, [isModalOpen, initialMode, voiceAvailable, llmAvailable]) - // Priority colors - const priorityColors = { - 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 priorityLabels = { - 0: '--', - 1: 'P1', - 2: 'P2', - 3: 'P3', - 4: 'P4', - } - // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { if (hasDescription && richTextEditorRef.current) { @@ -285,11 +344,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { useEffect(() => { const handleKeyDown = event => { const { - createChore, dueDate, handleCloseModal, hasDescription, isModalOpen, + submitChore, } = latestRef.current const isHoldingCmd = event.ctrlKey || event.metaKey if (isHoldingCmd) { @@ -327,7 +386,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { isModalOpen ) { event.preventDefault() - createChore() + submitChore() return } if (event.key === 'Escape' && isModalOpen) { @@ -481,14 +540,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { [], ) - const processText = useCallback( - sentence => { - const priority = parsePriority(sentence) - const pointsParsed = parsePoints(sentence) - const labels = parseLabels(sentence, userLabels || []) - - const circleMembersList = circleMembers?.res || [] - const assigneesForParsing = circleMembersList.map(member => ({ + // Rebuilt only when the member list actually changes, so a query refetch + // with identical data doesn't re-trigger the parse effect below + const assigneesForParsing = useMemo( + () => + (circleMembers?.res || []).map(member => ({ userId: member.userId, username: member.username || @@ -496,7 +552,15 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { displayName: member.displayName, name: member.displayName, id: member.userId, - })) + })), + [circleMembers], + ) + + const processText = useCallback( + sentence => { + const priority = parsePriority(sentence) + const pointsParsed = parsePoints(sentence) + const labels = parseLabels(sentence, userLabels || []) const assigneesResult = parseAssignees(sentence, assigneesForParsing) const repeat = parseRepeatV2(sentence) @@ -563,39 +627,47 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { syncDueDateStates(repeat.dueDate) } - // Create the cleaned sentence by sequentially applying all cleanups + // Create the cleaned sentence by sequentially applying all cleanups. + // Each stage only needs a reparse when an earlier cleanup actually + // changed the sentence; otherwise the first-pass result (computed on the + // identical string) is reused as-is. let cleanedSentence = sentence if (priority.result) cleanedSentence = priority.cleanedSentence if (pointsParsed.result) { - // Apply points cleaning to the current cleaned sentence - const pointsReparse = parsePoints(cleanedSentence) + const pointsReparse = + cleanedSentence === sentence + ? pointsParsed + : parsePoints(cleanedSentence) if (pointsReparse.result) cleanedSentence = pointsReparse.cleanedSentence } if (labels.result) { - // Apply labels cleaning to the current cleaned sentence - const labelsReparse = parseLabels(cleanedSentence, userLabels || []) + const labelsReparse = + cleanedSentence === sentence + ? labels + : parseLabels(cleanedSentence, userLabels || []) if (labelsReparse.result) cleanedSentence = labelsReparse.cleanedSentence } if (assigneesResult.result) { - // Apply assignees cleaning to the current cleaned sentence - const assigneesReparse = parseAssignees( - cleanedSentence, - assigneesForParsing, - ) + const assigneesReparse = + cleanedSentence === sentence + ? assigneesResult + : parseAssignees(cleanedSentence, assigneesForParsing) if (assigneesReparse.result) cleanedSentence = assigneesReparse.cleanedSentence } if (repeat.result) { - // Apply repeat cleaning to the current cleaned sentence - const repeatReparse = parseRepeatV2(cleanedSentence) + const repeatReparse = + cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence) if (repeatReparse.result) cleanedSentence = repeatReparse.cleanedSentence } if (dueDateParsed.result) { - // Apply date cleaning to the current cleaned sentence - const dueDateReparse = parseDueDate(cleanedSentence, chrono) + const dueDateReparse = + cleanedSentence === sentence + ? dueDateParsed + : parseDueDate(cleanedSentence, chrono) if (dueDateReparse.result) cleanedSentence = dueDateReparse.cleanedSentence } @@ -614,7 +686,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { assigneesResult.highlight, ) - setRenderedParts(parts) + setRenderedParts({ text: sentence, parts }) const overrides = pendingVoiceOverridesRef.current if (overrides) { @@ -644,7 +716,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } } }, - [userLabels, renderHighlightedSentence, circleMembers, userProfile], + [userLabels, renderHighlightedSentence, assigneesForParsing, userProfile], ) useEffect(() => { @@ -657,7 +729,16 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { return } - processText(taskText) + // Debounced so fast typing doesn't run the full parse pipeline per + // keystroke; submitChore flushes a pending parse before creating. + parseTimerRef.current = setTimeout(() => { + parseTimerRef.current = null + processText(taskText) + }, PARSE_DEBOUNCE_MS) + return () => { + clearTimeout(parseTimerRef.current) + parseTimerRef.current = null + } }, [ taskText, userLabelsLoading, @@ -722,7 +803,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } const handleEnterPressed = () => { - createChore() + submitChore() } // The scan keeps its source image when asked: upload it against the draft so @@ -964,11 +1045,26 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { handleCloseModal(false) } + // All submit paths (Enter, Cmd+Enter, footer button) go through here: a + // debounce may still be holding the parse of the latest text, and creating + // from pre-parse state would drop the tail of what the user typed. + const submitChore = () => { + if (parseTimerRef.current) { + clearTimeout(parseTimerRef.current) + parseTimerRef.current = null + flushSync(() => processText(taskText)) + } + // Read through latestRef: after the flush, this render's createChore + // closure is stale + latestRef.current.createChore() + } + latestRef.current = { isModalOpen, hasDescription, dueDate, createChore, + submitChore, handleCloseModal, } @@ -1038,7 +1134,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { color='primary' loading={isAttachingScan} disabled={!taskTitle.trim() || isAttachingScan} - onClick={createChore} + onClick={submitChore} > Create {showKeyboardShortcuts && ( @@ -1134,7 +1230,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { setTaskText(text) if (!text) setTaskTitle('') }} - customRenderer={renderedParts} + customRenderer={ + renderedParts.text === taskText + ? renderedParts.parts + : [taskText] + } onEnterPressed={handleEnterPressed} onShiftEnterPressed={() => { if (!hasDescription) { @@ -1142,45 +1242,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } setTimeout(() => richTextEditorRef.current?.focus(), 50) }} - suggestions={{ - '#': { - value: 'id', - display: 'name', - options: userLabels ? userLabels : [], - creatable: true, - onCreate: handleCreateLabel, - }, - '!': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: 'P1' }, - { id: '2', name: 'P2' }, - { id: '3', name: 'P3' }, - { id: '4', name: 'P4' }, - ], - }, - '@': { - value: 'userId', - display: 'displayName', - options: [ - { userId: 'anyone', displayName: 'Anyone' }, - ...(circleMembers?.res || []), - ], - }, - '*': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: '1 point' }, - { id: '5', name: '5 points' }, - { id: '10', name: '10 points' }, - { id: '25', name: '25 points' }, - { id: '50', name: '50 points' }, - { id: '100', name: '100 points' }, - ], - }, - }} + suggestions={smartInputSuggestions} /> @@ -1222,8 +1284,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { onChange={setPriority} onClear={() => setPriority(0)} emptyDisplay={pickerEmptyDisplay} - priorityColors={priorityColors} - priorityLabels={priorityLabels} + priorityColors={PRIORITY_COLORS} + priorityLabels={PRIORITY_LABELS} />