diff --git a/src/views/TestView/AutocompleteDropdown.jsx b/src/views/TestView/AutocompleteDropdown.jsx
index afe96b3..685800e 100644
--- a/src/views/TestView/AutocompleteDropdown.jsx
+++ b/src/views/TestView/AutocompleteDropdown.jsx
@@ -1,4 +1,5 @@
// AutocompleteDropdown.jsx
+import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
@@ -8,6 +9,7 @@ const AutocompleteDropdown = ({
selectedIndex,
onSelectSuggestion,
onMouseEnterSuggestion, // Added for hover selection
+ onCreateSuggestion, // Called when the "Create new" row is chosen
parentRefer, // Ref to the dropdown element
}) => {
// Scroll selected item into view
@@ -26,9 +28,28 @@ const AutocompleteDropdown = ({
return null // Don't render if no suggestions
}
+ const filteredOptions = suggestions.options.filter(option => {
+ if (typeof option === 'string') {
+ return option.toLowerCase().includes(currentValue.toLowerCase())
+ }
+ return option[suggestions.display]
+ .toLowerCase()
+ .includes(currentValue.toLowerCase())
+ })
+
+ const trimmedValue = currentValue.trim()
+ const hasExactMatch = filteredOptions.some(option => {
+ const optionText = suggestions.display
+ ? option[suggestions.display]
+ : option
+ return optionText.toLowerCase() === trimmedValue.toLowerCase()
+ })
+ const showCreateOption =
+ suggestions.creatable && trimmedValue.length > 0 && !hasExactMatch
+
return (
)
}
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 30ed6f5..bc2daa7 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -3,10 +3,12 @@ import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node'
import moment from 'moment'
+import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
+import { CreateLabel } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
@@ -24,7 +26,7 @@ import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
-import { TASK_COLOR } from '../../utils/Colors'
+import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
import AdvancedOptionsSection, {
AdvancedOptionsTrigger,
} from './AdvancedOptionsSection'
@@ -66,9 +68,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
useCircleMembers()
const { isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
+ const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
+ const handleCreateLabel = useCallback(
+ name => {
+ const color =
+ LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))]
+ .value
+ CreateLabel({ name, color })
+ .then(() => queryClient.invalidateQueries(['labels']))
+ .catch(error => console.error('Error creating label:', error))
+ },
+ [queryClient],
+ )
+
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
@@ -378,11 +393,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
if (priority.result) setPriority(parseInt(priority.result, 10))
if (pointsParsed.result) setPoints(pointsParsed.result)
if (labels.result) {
- // parseLabels returns array of label objects, extract their IDs
- const labelIds = labels.result
- .filter(label => label.id) // Only labels with IDs (existing labels)
- .map(label => label.id)
- setLabelsV2(labelIds)
+ // parseLabels only returns #mentions matched to an existing label
+ setLabelsV2(labels.result)
}
if (assigneesResult.isAnyone) {
@@ -856,6 +868,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
+ creatable: true,
+ onCreate: handleCreateLabel,
},
'!': {
value: 'id',
@@ -954,8 +968,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
/>
label.id)}
+ onChange={ids =>
+ setLabelsV2(
+ (userLabels || []).filter(label => ids.includes(label.id)),
+ )
+ }
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>
diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js
index df9a5d1..317be0e 100644
--- a/src/views/components/CustomParsers.js
+++ b/src/views/components/CustomParsers.js
@@ -97,64 +97,70 @@ export const parsePriority = inputSentence => {
}
}
export const parseLabels = (inputSentence, userLabels) => {
- let sentence = inputSentence.toLowerCase()
const currentLabels = []
- const newLabels = []
+ const matchedRanges = []
const allHighlights = []
- // Find all #label patterns in the sentence
- // Use [\p{L}\p{N}_]+ to support Unicode letters (including umlauts) and numbers
- const labelPattern = /#([\p{L}\p{N}_]+)/giu
- const matches = [...inputSentence.matchAll(labelPattern)]
+ // Labels can contain spaces (e.g. "New Label"), so a plain word-boundary
+ // regex can't capture them — check each '#' against real label names
+ // instead, longest name first so "New Label" wins over a label named "New".
+ const sortedLabels = [...userLabels].sort(
+ (a, b) => b.name.length - a.name.length,
+ )
+ const isWordChar = ch => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch)
- for (const match of matches) {
- const labelName = match[1]
- const fullMatch = match[0]
- const startIndex = match.index
+ const hashPattern = /#/g
+ let hashMatch
+ while ((hashMatch = hashPattern.exec(inputSentence)) !== null) {
+ const startIndex = hashMatch.index
+ const rest = inputSentence.slice(startIndex + 1)
- // Check if this label already exists
- const existingLabel = userLabels.find(
- label => label.name.toLowerCase() === labelName.toLowerCase(),
- )
+ const existingLabel = sortedLabels.find(label => {
+ const name = label.name
+ if (rest.toLowerCase().slice(0, name.length) !== name.toLowerCase()) {
+ return false
+ }
+ // Require a non-word boundary right after the name so "New" doesn't
+ // match inside a longer typed word like "Newer".
+ return !isWordChar(rest[name.length])
+ })
- if (existingLabel) {
- currentLabels.push(existingLabel)
- } else {
- // Create a new label object for new labels
- newLabels.push({
- name: labelName,
- color: '#3b82f6', // Default blue color
- isNew: true,
- })
- }
+ // Unmatched #mentions (no existing label, not created) are left as plain
+ // text — only #mentions resolving to a real label are extracted/cleaned.
+ if (!existingLabel) continue
- allHighlights.push({
- text: fullMatch,
+ const fullMatch = `#${existingLabel.name}`
+ currentLabels.push(existingLabel)
+ matchedRanges.push({
start: startIndex,
end: startIndex + fullMatch.length,
})
-
- // Remove the label from the sentence
- sentence = sentence.replace(fullMatch.toLowerCase(), '')
+ allHighlights.push({
+ text: inputSentence.slice(startIndex, startIndex + fullMatch.length),
+ start: startIndex,
+ end: startIndex + fullMatch.length,
+ })
+ hashPattern.lastIndex = startIndex + fullMatch.length
}
- const allLabels = [...currentLabels, ...newLabels]
+ if (currentLabels.length > 0) {
+ let cleanedSentence = inputSentence
+ for (const range of matchedRanges
+ .slice()
+ .sort((a, b) => b.start - a.start)) {
+ cleanedSentence =
+ cleanedSentence.slice(0, range.start) + cleanedSentence.slice(range.end)
+ }
- if (allLabels.length > 0) {
return {
- result: allLabels,
- newLabels: newLabels,
+ result: currentLabels,
highlight: allHighlights,
- cleanedSentence: inputSentence
- .replace(labelPattern, '')
- .replace(/\s+/g, ' ')
- .trim(),
+ cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(),
}
}
return {
result: null,
- newLabels: [],
cleanedSentence: inputSentence,
}
}
diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx
index 39fa034..9558b5f 100644
--- a/src/views/components/SmartTaskTitleInput.jsx
+++ b/src/views/components/SmartTaskTitleInput.jsx
@@ -111,46 +111,71 @@ const SmartTaskTitleInput = ({
setCursorPosition(e.target.selectionStart)
}
+ const selectSuggestionText = suggestionValue => {
+ const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
+ onChange(newValue)
+ titleInputRef.current.value = newValue
+
+ setShowSuggestions(false)
+
+ const newCursorPosition =
+ cursorPosition - lastWord.length + suggestionValue.length + 1
+ titleInputRef.current.setSelectionRange(
+ newCursorPosition,
+ newCursorPosition,
+ )
+ }
+
const handleTextareaKeyDown = e => {
if (showSuggestions) {
- const currentSuggestions = suggestions[suggestionTrigger].options.filter(
- option => {
- if (typeof option === 'string') {
- return option.toLowerCase().includes(lastWord.toLowerCase())
- }
- return option[suggestions[suggestionTrigger].display]
- .toLowerCase()
- .includes(lastWord.toLowerCase())
- },
- )
+ const activeSuggestions = suggestions[suggestionTrigger]
+ const currentSuggestions = activeSuggestions.options.filter(option => {
+ if (typeof option === 'string') {
+ return option.toLowerCase().includes(lastWord.toLowerCase())
+ }
+ return option[activeSuggestions.display]
+ .toLowerCase()
+ .includes(lastWord.toLowerCase())
+ })
+
+ const trimmedWord = lastWord.trim()
+ const hasExactMatch = currentSuggestions.some(option => {
+ const optionText = activeSuggestions.display
+ ? option[activeSuggestions.display]
+ : option
+ return optionText.toLowerCase() === trimmedWord.toLowerCase()
+ })
+ const showCreateOption =
+ activeSuggestions.creatable && trimmedWord.length > 0 && !hasExactMatch
+ const optionCount = currentSuggestions.length + (showCreateOption ? 1 : 0)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
+ if (optionCount === 0) return
const newIndex =
e.key === 'ArrowDown'
- ? (selectedSuggestionIndex + 1) % currentSuggestions.length
- : (selectedSuggestionIndex - 1 + currentSuggestions.length) %
- currentSuggestions.length
+ ? (selectedSuggestionIndex + 1) % optionCount
+ : (selectedSuggestionIndex - 1 + optionCount) % optionCount
setSelectedSuggestionIndex(newIndex)
} else if (e.key === 'Enter' || e.key === 'Tab') {
- e.preventDefault()
+ if (
+ showCreateOption &&
+ selectedSuggestionIndex === currentSuggestions.length
+ ) {
+ e.preventDefault()
+ selectSuggestionText(trimmedWord)
+ activeSuggestions.onCreate?.(trimmedWord)
+ return
+ }
+
const selectedSuggestion = currentSuggestions[selectedSuggestionIndex]
- const suggestionValue = suggestions[suggestionTrigger].display
- ? selectedSuggestion[suggestions[suggestionTrigger].display]
+ const suggestionValue = activeSuggestions.display
+ ? selectedSuggestion?.[activeSuggestions.display]
: selectedSuggestion
if (suggestionValue) {
- const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}`
- onChange(newValue)
- titleInputRef.current.value = newValue
-
- setShowSuggestions(false)
-
- const newCursorPosition = cursorPosition + suggestionValue.length + 1
- titleInputRef.current.setSelectionRange(
- newCursorPosition,
- newCursorPosition,
- )
+ e.preventDefault()
+ selectSuggestionText(suggestionValue)
}
} else if (e.key === 'Escape') {
e.preventDefault()
@@ -349,6 +374,10 @@ const SmartTaskTitleInput = ({
)
setShowSuggestions(false)
}}
+ onCreateSuggestion={name => {
+ selectSuggestionText(name)
+ suggestions[suggestionTrigger].onCreate?.(name)
+ }}
parentRefer={dropdownRef}
/>
)}