this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: autosuggest tags and projects

+404 -138
+110 -75
mast-react-vite/src/App.tsx
··· 1 - import { useState, useEffect, useMemo, useRef } from "react"; 1 + import { useState, useEffect, useMemo } from "react"; 2 2 import { useQuery } from "@vlcn.io/react"; 3 + import { DataTable } from "@/components/ui/data-table"; 3 4 import * as commandParser from "@/lib/command_js.js"; 4 - import { Checkbox } from "@/components/ui/checkbox"; 5 5 import { ActionParser } from "@/components/ui/action-parser"; 6 6 import { useSelection } from "@/contexts/selection-context"; 7 7 import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; 8 8 import { AppSidebar } from "@/components/ui/app-sidebar"; 9 - import { DataTable } from "@/components/ui/data-table"; 10 9 import { Badge } from "@/components/ui/badge"; 11 10 12 11 type Todo = { ··· 17 16 project: string; 18 17 }; 19 18 20 - function App({ ctx, syncWorker, dbname }) { 19 + function App({ ctx }) { 21 20 const [newText, setNewText] = useState(""); 22 21 const { selectedItems, clearSelection, getSelectionString } = useSelection(); 23 22 const [currentAction, setCurrentAction] = useState("add"); 24 23 const [filterContext, setFilterContext] = useState({}); 24 + 25 25 const newConditions = []; 26 26 const newParams = []; 27 27 ··· 32 32 newConditions.push("description LIKE ?"); 33 33 newParams.push(`%${filterContext.filterDescription}%`); 34 34 } 35 - 36 35 if (filterContext.filterProject && filterContext.filterProject.length > 0) { 37 36 newConditions.push("project = ?"); 38 37 newParams.push(filterContext.filterProject); 39 38 } 40 - 41 39 if (filterContext.filterTags && filterContext.filterTags.length > 0) { 42 40 newConditions.push(`EXISTS ( 43 41 SELECT 1 FROM json_each(tags) ··· 61 59 `SELECT * FROM active_todos ${whereClause ? "WHERE " + whereClause : ""}`, 62 60 newParams, 63 61 ).data; 64 - 65 - // Trigger sync when todos change 66 - useEffect(() => { 67 - if (syncWorker && todos.length > 0) { 68 - syncWorker.postMessage({ 69 - type: 'SYNC_CHANGES', 70 - dbname 71 - }); 72 - } 73 - }, [todos, syncWorker]); 74 62 75 63 useEffect(() => { 76 64 if ( ··· 90 78 ); 91 79 } catch (error) { 92 80 console.log("Could not parse command"); 93 - console.log(getSelectionString() + " " + currentAction + " " + newText); 81 + console.log( getSelectionString() + " " + currentAction + " " + newText) 94 82 return {}; 95 83 } 96 84 }, [getSelectionString(), currentAction, newText]); ··· 109 97 working_id: 0, 110 98 }; 111 99 return [newTodo, ...todos]; 100 + 112 101 case "done": 113 102 return todos.map((todo) => { 114 103 if (selectedItems.has(todo.working_id)) { ··· 116 105 } 117 106 return todo; 118 107 }); 119 - case "modify": 120 - return todos.map((todo) => { 121 - if (selectedItems.has(todo.working_id)) { 122 - // Create a preview object that contains the original todo and the changes 123 - const previewChanges = {}; 124 - 125 - // Only add fields that are being changed to the preview 126 - if (parsedCommand.description) { 127 - previewChanges.description = parsedCommand.description; 128 - } 129 - 130 - if (parsedCommand.project) { 131 - previewChanges.project = parsedCommand.project; 132 - } 133 - 134 - if (parsedCommand.tags && parsedCommand.tags.length > 0) { 135 - // For tags, we want to show both existing and new tags in preview 136 - previewChanges.tags = JSON.stringify([ 137 - ...parsedCommand.tags 138 - ]); 139 - } 140 - 141 - // Return the original todo with preview changes attached 142 - return { 143 - ...todo, 144 - preview: previewChanges, 145 - previewMode: true 146 - }; 147 - } 148 - return todo; 108 + 109 + case "modify": 110 + return todos.map((todo) => { 111 + if (selectedItems.has(todo.working_id)) { 112 + // Create a preview object that contains the original todo and the changes 113 + const previewChanges = {}; 114 + 115 + // Only add fields that are being changed to the preview 116 + if (parsedCommand.description) { 117 + previewChanges.description = parsedCommand.description; 118 + } 119 + 120 + if (parsedCommand.project) { 121 + previewChanges.project = parsedCommand.project; 122 + } 123 + 124 + if (parsedCommand.tags && parsedCommand.tags.length > 0) { 125 + // For tags, we want to show both existing and new tags in preview 126 + previewChanges.tags = JSON.stringify([ 127 + ...parsedCommand.tags 128 + ]); 129 + } 130 + 131 + // Return the original todo with preview changes attached 132 + return { 133 + ...todo, 134 + preview: previewChanges, 135 + previewMode: true 136 + }; 137 + } 138 + return todo; 149 139 }); 140 + 141 + 150 142 case "filter": 151 143 // For filter, we don't need preview todos 152 144 return []; 145 + 153 146 default: 154 147 return []; 155 148 } ··· 199 192 selection.forEach((sel) => { 200 193 if (sel.type === "id") { 201 194 conditions.push(`id IN ( 202 - SELECT id 203 - FROM active_todos 204 - WHERE working_id IN (${sel.ids.map(() => "?").join(",")}) 205 - )`); 195 + SELECT id 196 + FROM active_todos 197 + WHERE working_id IN (${sel.ids.map(() => "?").join(",")}) 198 + )`); 206 199 params.push(...sel.ids); 207 200 } else if (sel.type === "tag") { 208 201 // TODO this is busted ··· 231 224 conditions: [], 232 225 params: [], 233 226 }); 227 + 234 228 if (conditions.length > 0) { 235 229 const sqlQuery = ` 236 - UPDATE todos 237 - SET completed = 1 238 - WHERE ${conditions.join(" OR ")} 239 - `; 230 + UPDATE todos 231 + SET completed = 1 232 + WHERE ${conditions.join(" OR ")} 233 + `; 234 + 240 235 ctx.db.exec(sqlQuery, params); 241 236 clearSelection(); 242 237 } ··· 251 246 case "modify": 252 247 const editParams = []; 253 248 const updates = []; 249 + 254 250 // Handle new values for SET clause 255 251 if (parsed.description && parsed.description.length > 0) { 256 252 updates.push("description = ?"); ··· 262 258 } 263 259 if (parsed.tags && parsed.tags.length > 0) { 264 260 updates.push(`tags = ( 265 - SELECT json_group_array(value) 266 - FROM ( 267 - SELECT DISTINCT value 268 - FROM ( 269 - SELECT value FROM json_each(tags) 270 - UNION 271 - SELECT value FROM json_each(json(?)) 272 - ) 273 - ) 274 - )`); 261 + SELECT json_group_array(value) 262 + FROM ( 263 + SELECT DISTINCT value 264 + FROM ( 265 + SELECT value FROM json_each(tags) 266 + UNION 267 + SELECT value FROM json_each(json(?)) 268 + ) 269 + ) 270 + )`); 275 271 editParams.push(JSON.stringify(parsed.tags)); 276 272 } 273 + 277 274 // Build WHERE clause using only selection conditions 275 + 278 276 const { conditions: editConditions, params: selectionParams } = 279 277 parseSelection({ 280 278 selection: parsed.selection, 281 279 conditions: [], 282 280 params: [], 283 281 }); 282 + 284 283 if (editConditions.length > 0 && updates.length > 0) { 285 284 const sqlQuery = ` 286 285 UPDATE todos ··· 305 304 break; 306 305 } 307 306 setNewText(""); 308 - 309 307 } catch (error) { 310 308 // TODO: 311 309 // This is actually bad ··· 316 314 } 317 315 }; 318 316 317 + //const parseTodos = (e) => { 318 + // // On enter execute the command 319 + // if (e.key === "Enter") { 320 + // // TODO We should pass selectionContext here 321 + // executeCommand(currentAction + " " + e.target.value); 322 + // } 323 + // // React to key presses for selection 324 + // else if (e.target.value.trim() !== "") { 325 + // try { 326 + // // TODO: 327 + // // This allows editing selectionContext from the newText field 328 + // // But we've moved selectionContext into it's own place 329 + // // So we shouldn't have to do this here 330 + // const parsed = commandParser.parse(currentAction + " " + e.target.value); 331 + // if (parsed.filters && parsed.filters.length > 0) { 332 + // const idFilters = parsed.filters.filter(f => f.type === "id"); 333 + // // Create new selection state 334 + // const newSelection = {}; 335 + // idFilters.forEach(filter => { 336 + // filter.ids.forEach(id => { 337 + // newSelection[id - 1] = true; 338 + // }) 339 + // }); 340 + 341 + // } 342 + // } catch (error) { 343 + // console.log("Unable to parse field onUpdate") 344 + // return; 345 + // } 346 + // } else { 347 + // } 348 + //} 349 + 319 350 return ( 320 351 <> 321 - <SidebarProvider defaultOpen={false} className="h-screen dark"> 352 + <SidebarProvider defaultOpen={false} className="h-screen"> 322 353 <div className="hidden md:flex flex-col w-full h-svh"> 323 - <AppSidebar ctx={ctx} 324 - filterContext={filterContext} 354 + <AppSidebar ctx={ctx} 355 + filterContext={filterContext} 325 356 setFilterContext={setFilterContext} /> 357 + 326 358 <div className="bg-muted h-14 w-full absolute top-0 " /> 327 359 <section className="flex-1 container py-12 h-[calc(100vh-theme(spacing.4))] overflow-hidden relative"> 328 360 <SidebarTrigger className="absolute top-4 left-8 border border-foreground" /> ··· 335 367 {todos.length} items found 336 368 <button 337 369 onClick={() => setFilterContext({})} 338 - className="hover:bg-muted rounded-full p-1"> 370 + className="hover:bg-muted rounded-full p-1" 371 + > 339 372 340 373 </button> 341 374 </Badge> ··· 346 379 type="text" 347 380 className="bg-background" 348 381 value={newText} 382 + ctx={ctx} 349 383 onActionChange={handleActionChange} 350 384 onKeyUp={(e) => handleEnter(e)} 351 385 onChange={(e) => handleNewTextChange(e.target.value)} ··· 357 391 </div> 358 392 </section> 359 393 </div> 394 + 360 395 {/* Mobile view layout - shown only on mobile */} 361 396 <div className="md:hidden flex left-0 w-full h-full flex flex-col items-center pb-2"> 362 - <AppSidebar ctx={ctx} 363 - filterContext={filterContext} 364 - setFilterContext={setFilterContext} /> 397 + <AppSidebar ctx={ctx} 398 + filterContext={filterContext} 399 + setFilterContext={setFilterContext} /> 400 + 365 401 <section className="flex-1 py-12 h-[calc(100vh-theme(spacing.4))] w-full overflow-hidden relative"> 366 402 <div className="flex-1 bg-muted border-b border-foreground h-14 absolute inset-x-0 top-0 " /> 367 403 <SidebarTrigger className="fixed top-4 border border-foreground left-8" /> ··· 385 421 type="text" 386 422 className="bg-background" 387 423 value={newText} 424 + ctx={ctx} 388 425 onActionChange={handleActionChange} 389 426 onKeyUp={(e) => handleEnter(e)} 390 427 onChange={(e) => handleNewTextChange(e.target.value)} ··· 396 433 </div> 397 434 </section> 398 435 </div> 399 - 400 436 </SidebarProvider> 401 437 </> 402 438 ); 403 439 } 404 440 405 441 export default App; 406 -
+294 -63
mast-react-vite/src/components/ui/action-parser.tsx
··· 1 1 import * as React from "react"; 2 - 3 2 import { cn } from "@/lib/utils"; 4 3 import { Button } from "@/components/ui/button"; 5 4 import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 6 - import { Search, Plus, Pencil, Check } from "lucide-react"; 5 + import { Search, Plus, Pencil, Check, Calendar } from "lucide-react"; 7 6 8 7 // Add onActionChange to InputProps interface 9 8 export interface InputProps 10 9 extends React.InputHTMLAttributes<HTMLInputElement> { 11 10 onActionChange?: (action: string) => void; 12 11 onSubmit?: () => void; // Add this prop 12 + ctx?: any; 13 13 } 14 14 15 15 const ActionParser = React.forwardRef<HTMLInputElement, InputProps>( 16 - ({ className, type, onActionChange, onSubmit, value, ...props }, ref) => { 16 + ({ className, ctx, type, onActionChange, onSubmit, value, ...props }, ref) => { 17 17 const [selectedIndex, setSelectedIndex] = React.useState(0); 18 18 const [selectedToggle, setSelectedToggle] = React.useState("add"); 19 19 const options = ["add", "filter", "done", "delete"]; 20 - 21 - //const handleWheel = (event: React.WheelEvent) => { 22 - // event.preventDefault() 23 - // if (event.deltaY > 0) { 24 - // const newIndex = (selectedIndex + 1) % options.length 25 - // setSelectedIndex(newIndex) 26 - // onActionChange?.(options[newIndex]) 27 - // } else { 28 - // const newIndex = (selectedIndex - 1 + options.length) % options.length 29 - // setSelectedIndex(newIndex) 30 - // onActionChange?.(options[newIndex]) 31 - // } 32 - //} 20 + const [suggestions, setSuggestions] = React.useState<string[]>([]); 33 21 34 22 const handleToggleChange = (value: string) => { 35 23 if (value) { ··· 45 33 } 46 34 }; 47 35 36 + React.useEffect(() => { 37 + if (!ctx) return; 38 + 39 + const trimmedValue = (value as string || '').trim(); 40 + 41 + // Get the last word from the input 42 + const words = trimmedValue.split(/\s+/); 43 + const lastWord = words[words.length - 1] || ''; 44 + 45 + const fetchSuggestions = async () => { 46 + try { 47 + if (lastWord.startsWith('#')) { 48 + // Extract the partial tag (text after the #) 49 + const partialTag = lastWord.substring(1); 50 + 51 + // Query for tags that match the partial input 52 + const result = await ctx.db.exec( 53 + `SELECT DISTINCT value as tag FROM todos, json_each(todos.tags) 54 + WHERE completed = 0 AND value LIKE ? LIMIT 5`, 55 + [`${partialTag}%`] // Use parameter to prevent SQL injection 56 + ); 57 + 58 + // Process the results 59 + let tags: string[] = []; 60 + if (result && Array.isArray(result)) { 61 + if (result.length > 0) { 62 + if (Array.isArray(result[0])) { 63 + tags = result.map(row => String(row[0])); 64 + } else if (typeof result[0] === 'object' && result[0] !== null) { 65 + tags = result.map(row => String(row.tag || row[0])); 66 + } 67 + } 68 + } 69 + 70 + setSuggestions(tags); 71 + } 72 + else if (lastWord.startsWith('+')) { 73 + // Extract the partial project (text after the +) 74 + const partialProject = lastWord.substring(1); 75 + 76 + // Query for projects that match the partial input 77 + const result = await ctx.db.exec( 78 + `SELECT DISTINCT project FROM todos 79 + WHERE project != '' AND completed = 0 AND project LIKE ? LIMIT 5`, 80 + [`${partialProject}%`] 81 + ); 82 + 83 + let projects: string[] = []; 84 + if (result && Array.isArray(result)) { 85 + if (result.length > 0) { 86 + if (Array.isArray(result[0])) { 87 + projects = result.map(row => String(row[0])); 88 + } else if (typeof result[0] === 'object' && result[0] !== null) { 89 + projects = result.map(row => String(row.project || row[0])); 90 + } 91 + } 92 + } 93 + 94 + setSuggestions(projects); 95 + } 96 + else { 97 + setSuggestions([]); 98 + } 99 + } catch (error) { 100 + console.error("Error fetching suggestions:", error); 101 + setSuggestions([]); 102 + } 103 + }; 104 + 105 + fetchSuggestions(); 106 + 107 + }, [value, ctx]); 108 + 48 109 return ( 49 110 <div className="flex flex-col group"> 50 111 <div className="hidden md:flex flex-col w-full"> ··· 85 146 </div> 86 147 <div className="flex-1"></div> 87 148 </div> 88 - <div className="relative w-full"> 89 - <input 90 - type={type} 91 - placeholder={selectedToggle} 92 - className={cn( 93 - "flex-1 h-9 w-full border rounded-r-md rounded-tl-none rounded-bl-md bg-background px-3 pr-12 text-base", 94 - "shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1", 95 - "disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 96 - "group-focus-within:border-ring", 97 - className, 98 - )} 99 - ref={ref} 100 - value={value} 101 - {...props} 102 - /> 103 - <Button 104 - type="button" 105 - onClick={handleSubmit} 106 - className="absolute right-1 bottom-1 h-7 z-10" 107 - > 108 - 109 - </Button> 149 + 150 + {/* Two-line input appearance with divider */} 151 + <div className="relative w-full flex flex-col"> 152 + {/* First line (interactive input) */} 153 + <div className="flex-1 min-h-[36px] border border-b-0 rounded-br-none rounded-tr-md rounded-tl-none bg-background px-3 py-2 text-sm flex items-center group-focus-within:border-ring"> 154 + <input 155 + type={type} 156 + placeholder={selectedToggle} 157 + className={cn( 158 + "w-full bg-transparent border-none outline-none p-0 text-base", 159 + "placeholder:text-muted-foreground", 160 + "disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 161 + "focus:outline-none", 162 + className, 163 + )} 164 + ref={ref} 165 + value={value} 166 + {...props} 167 + /> 168 + </div> 169 + 170 + {/* Divider line */} 171 + <div className="w-[97%] mx-auto h-[1px] bg-border"></div> 172 + 173 + {/* Second line (non-interactive) with submit button */} 174 + <div className="flex-1 min-h-[36px] border border-t-0 rounded-r-none rounded-bl-md rounded-br-md bg-background px-3 py-2 text-sm text-muted-foreground flex items-center justify-between group-focus-within:border-ring"> 175 + <div className="flex space-x-3"> 176 + <Button 177 + variant="ghost" 178 + size="sm" 179 + onClick={() => { 180 + const input = document.querySelector('input'); 181 + if (input) { 182 + const newValue = `${value.trim() + " " || ''} #`; 183 + props.onChange?.({ target: { value: newValue } } as any); 184 + } 185 + }} 186 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 187 + > 188 + # 189 + </Button> 190 + <Button 191 + variant="ghost" 192 + size="sm" 193 + onClick={() => { 194 + const input = document.querySelector('input'); 195 + if (input) { 196 + const newValue = `${value.trim() + " " || ''}+`; 197 + props.onChange?.({ target: { value: newValue } } as any); 198 + } 199 + }} 200 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 201 + > 202 + + 203 + </Button> 204 + <Button 205 + variant="ghost" 206 + size="sm" 207 + //onClick={() => { 208 + // const input = document.querySelector('input'); 209 + // if (input) { 210 + // const newValue = `${value || ''} 📅`; 211 + // props.onChange?.({ target: { value: newValue } } as any); 212 + // } 213 + //}} 214 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 215 + > 216 + <Calendar /> 217 + </Button> 218 + {suggestions.length > 0 && ( 219 + <div className="flex space-x-2 overflow-x-auto"> 220 + {suggestions.map((suggestion, index) => ( 221 + <Button 222 + key={index} 223 + variant="ghost" 224 + size="sm" 225 + onClick={() => { 226 + const trimmedValue = (value as string || '').trim(); 227 + const words = trimmedValue.split(/\s+/); 228 + const lastWord = words[words.length - 1] || ''; 229 + 230 + // Replace just the last word with the suggestion 231 + if (lastWord.startsWith('#')) { 232 + words[words.length - 1] = `#${suggestion}`; 233 + } else if (lastWord.startsWith('+')) { 234 + words[words.length - 1] = `+${suggestion}`; 235 + } 236 + 237 + const newValue = words.join(' '); 238 + props.onChange?.({ target: { value: newValue } } as any); 239 + }} 240 + className="h-7 px-2 text-muted-foreground hover:text-foreground whitespace-nowrap" 241 + > 242 + {suggestion} 243 + </Button> 244 + ))} 245 + </div> 246 + )} 247 + 248 + </div> 249 + <Button 250 + type="button" 251 + onClick={handleSubmit} 252 + className="h-8 z-10" 253 + > 254 + 255 + </Button> 256 + </div> 110 257 </div> 111 258 </div> 112 259 ··· 157 304 <div className="absolute bottom-0 left-0 right-0 h-[2px] border border-b-0 border-t-0 group-focus-within:border-ring bg-background z-20 translate-y-[1px]" /> 158 305 </div> 159 306 160 - <div className="relative w-[90%] mb-1"> 161 - <input 162 - type={type} 163 - placeholder={selectedToggle} 164 - className={cn( 165 - "flex-1 h-9 w-full border bg-background px-3 pr-12 text-base", 166 - "shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1", 167 - "disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 168 - "group-focus-within:border-ring", 169 - // Modify border radius based on viewport size 170 - "md:rounded-r-md md:rounded-tl-none md:rounded-bl-md", 171 - "rounded-md", // For mobile view (full rounded corners) 172 - className, 173 - )} 174 - ref={ref} 175 - value={value} 176 - {...props} 177 - /> 178 - <Button 179 - type="button" 180 - onClick={handleSubmit} 181 - className="absolute right-1 bottom-1 h-7 z-10" 182 - > 183 - 184 - </Button> 307 + {/* Mobile two-line input appearance */} 308 + <div className="relative w-[90%] mb-1 flex flex-col"> 309 + {/* First line (interactive input) */} 310 + <div className="flex-1 min-h-[36px] border border-b-0 rounded-t-md bg-background px-3 py-2 text-sm flex items-center group-focus-within:border-ring"> 311 + <input 312 + type={type} 313 + placeholder={selectedToggle} 314 + className={cn( 315 + "w-full bg-transparent border-none outline-none p-0 text-base", 316 + "placeholder:text-muted-foreground", 317 + "disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 318 + "focus:outline-none", 319 + className, 320 + )} 321 + ref={ref} 322 + value={value} 323 + {...props} 324 + /> 325 + </div> 326 + 327 + {/* Divider line */} 328 + <div className="w-[97%] mx-auto h-[1px] bg-border"></div> 329 + 330 + {/* Second line (non-interactive) with submit button */} 331 + <div className="flex-1 min-h-[36px] border border-t-0 rounded-b-md bg-background px-3 py-2 text-sm text-muted-foreground flex items-center justify-between group-focus-within:border-ring"> 332 + <div className="flex space-x-3"> 333 + <Button 334 + variant="ghost" 335 + size="sm" 336 + onClick={() => { 337 + const input = document.querySelector('input'); 338 + if (input) { 339 + const newValue = `${value || ''} #`; 340 + props.onChange?.({ target: { value: newValue } } as any); 341 + } 342 + }} 343 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 344 + > 345 + # 346 + </Button> 347 + <Button 348 + variant="ghost" 349 + size="sm" 350 + onClick={() => { 351 + const input = document.querySelector('input'); 352 + if (input) { 353 + const newValue = `${value || ''} +`; 354 + props.onChange?.({ target: { value: newValue } } as any); 355 + } 356 + }} 357 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 358 + > 359 + + 360 + </Button> 361 + <Button 362 + variant="ghost" 363 + size="sm" 364 + //onClick={() => { 365 + // const input = document.querySelector('input'); 366 + // if (input) { 367 + // const newValue = `${value || ''} 📅`; 368 + // props.onChange?.({ target: { value: newValue } } as any); 369 + // } 370 + //}} 371 + className="h-8 px-2 text-muted-foreground hover:text-foreground" 372 + > 373 + <Calendar /> 374 + </Button> 375 + {suggestions.length > 0 && ( 376 + <div className="flex space-x-2 overflow-x-auto"> 377 + {suggestions.map((suggestion, index) => ( 378 + <Button 379 + key={index} 380 + variant="ghost" 381 + size="sm" 382 + onClick={() => { 383 + const trimmedValue = (value as string || '').trim(); 384 + const words = trimmedValue.split(/\s+/); 385 + const lastWord = words[words.length - 1] || ''; 386 + 387 + // Replace just the last word with the suggestion 388 + if (lastWord.startsWith('#')) { 389 + words[words.length - 1] = `#${suggestion}`; 390 + } else if (lastWord.startsWith('+')) { 391 + words[words.length - 1] = `+${suggestion}`; 392 + } 393 + 394 + const newValue = words.join(' '); 395 + props.onChange?.({ target: { value: newValue } } as any); 396 + }} 397 + className="h-7 px-2 text-muted-foreground hover:text-foreground whitespace-nowrap" 398 + > 399 + {suggestion} 400 + </Button> 401 + ))} 402 + </div> 403 + )} 404 + 405 + </div> 406 + <Button 407 + type="button" 408 + onClick={handleSubmit} 409 + className="h-8 z-10" 410 + > 411 + 412 + </Button> 413 + </div> 185 414 </div> 186 415 </div> 187 416 </div> 188 417 ); 189 418 }, 190 419 ); 420 + 191 421 ActionParser.displayName = "ActionParser"; 192 422 193 423 export { ActionParser }; 424 +