diff --git a/ui/desktop/src/components/ActionPopover.tsx b/ui/desktop/src/components/ActionPopover.tsx new file mode 100644 index 0000000000..7da65b78ab --- /dev/null +++ b/ui/desktop/src/components/ActionPopover.tsx @@ -0,0 +1,194 @@ +import { + + useEffect, + useRef, + forwardRef, + useImperativeHandle, +} from 'react'; +import { Zap, FileText, Code, Settings, Search, Play } from 'lucide-react'; + +interface ActionItem { + id: string; + label: string; + description: string; + icon: React.ReactNode; + action: () => void; +} + +interface ActionPopoverProps { + isOpen: boolean; + onClose: () => void; + onSelect: (actionId: string) => void; + position: { x: number; y: number }; + selectedIndex: number; + onSelectedIndexChange: (index: number) => void; +} + +const ActionPopover = forwardRef< + { getDisplayActions: () => ActionItem[]; selectAction: (index: number) => void }, + ActionPopoverProps +>(({ isOpen, onClose, onSelect, position, selectedIndex, onSelectedIndexChange }, ref) => { + const popoverRef = useRef(null); + const listRef = useRef(null); + + // Define available actions + const actions: ActionItem[] = [ + { + id: 'quick-task', + label: 'Quick Task', + description: 'Create a quick task or reminder', + icon: , + action: () => { + // TODO: Implement quick task creation + console.log('Quick task action triggered'); + }, + }, + { + id: 'generate-code', + label: 'Generate Code', + description: 'Generate code snippet or template', + icon: , + action: () => { + // TODO: Implement code generation + console.log('Generate code action triggered'); + }, + }, + { + id: 'create-document', + label: 'Create Document', + description: 'Create a new document or file', + icon: , + action: () => { + // TODO: Implement document creation + console.log('Create document action triggered'); + }, + }, + { + id: 'search-files', + label: 'Search Files', + description: 'Search through project files', + icon: , + action: () => { + // TODO: Implement file search + console.log('Search files action triggered'); + }, + }, + { + id: 'run-command', + label: 'Run Command', + description: 'Execute a shell command', + icon: , + action: () => { + // TODO: Implement command execution + console.log('Run command action triggered'); + }, + }, + { + id: 'settings', + label: 'Settings', + description: 'Open settings and preferences', + icon: , + action: () => { + // TODO: Implement settings navigation + console.log('Settings action triggered'); + }, + }, + ]; + + // Expose methods to parent component + useImperativeHandle( + ref, + () => ({ + getDisplayActions: () => actions, + selectAction: (index: number) => { + if (actions[index]) { + onSelect(actions[index].id); + actions[index].action(); + onClose(); + } + }, + }), + [actions, onSelect, onClose] + ); + + // Handle clicks outside the popover + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selectedElement = listRef.current.children[selectedIndex] as HTMLElement; + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest' }); + } + } + }, [selectedIndex]); + + const handleItemClick = (index: number) => { + onSelectedIndexChange(index); + onSelect(actions[index].id); + actions[index].action(); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Quick Actions

+

Choose an action to perform

+
+ +
+ {actions.map((action, index) => ( +
handleItemClick(index)} + className={`flex items-center gap-3 p-3 rounded-md cursor-pointer transition-colors ${ + index === selectedIndex + ? 'bg-bgProminent text-textProminentInverse' + : 'hover:bg-bgSubtle' + }`} + > +
+ {action.icon} +
+
+
{action.label}
+
{action.description}
+
+
+ ))} +
+
+
+ ); +}); + +ActionPopover.displayName = 'ActionPopover'; + +export default ActionPopover; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index b19c32be90..1f5bd29586 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Button } from './ui/button'; import type { View } from '../utils/navigationUtils'; import Stop from './ui/Stop'; -import { Attach, Send, Close, Microphone } from './icons'; +import { Attach, Send, Close, Microphone, Action } from './icons'; import { ChatState } from '../types/chatState'; import debounce from 'lodash/debounce'; import { LocalMessageStorage } from '../utils/localMessageStorage'; @@ -19,6 +19,7 @@ import { useWhisper } from '../hooks/useWhisper'; import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; import MentionPopover, { FileItemWithMatch } from './MentionPopover'; +import ActionPopover from './ActionPopover'; import { useDictationSettings } from '../hooks/useDictationSettings'; import { useContextManager } from './context_management/ContextManager'; import { useChatContext } from '../contexts/ChatContext'; @@ -232,6 +233,19 @@ export default function ChatInput({ mentionStart: -1, selectedIndex: 0, }); + const [actionPopover, setActionPopover] = useState<{ + isOpen: boolean; + position: { x: number; y: number }; + selectedIndex: number; + }>({ + isOpen: false, + position: { x: 0, y: 0 }, + selectedIndex: 0, + }); + const actionPopoverRef = useRef<{ + getDisplayActions: () => any[]; + selectAction: (index: number) => void; + }>(null); const mentionPopoverRef = useRef<{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void; @@ -675,38 +689,59 @@ export default function ChatInput({ }; const checkForMention = (text: string, cursorPosition: number, textArea: HTMLTextAreaElement) => { - // Find the last @ before the cursor + // Find the last @ and / before the cursor const beforeCursor = text.slice(0, cursorPosition); const lastAtIndex = beforeCursor.lastIndexOf('@'); + const lastSlashIndex = beforeCursor.lastIndexOf('/'); + + // Determine which symbol is closer to cursor + const isSlashTrigger = lastSlashIndex > lastAtIndex; + const triggerIndex = isSlashTrigger ? lastSlashIndex : lastAtIndex; - if (lastAtIndex === -1) { - // No @ found, close mention popover + if (triggerIndex === -1) { + // No trigger symbol found, close both popovers setMentionPopover((prev) => ({ ...prev, isOpen: false })); + setActionPopover((prev) => ({ ...prev, isOpen: false })); return; } - // Check if there's a space between @ and cursor (which would end the mention) - const afterAt = beforeCursor.slice(lastAtIndex + 1); - if (afterAt.includes(' ') || afterAt.includes('\n')) { + // Check if there's a space between trigger symbol and cursor (which would end the trigger) + const afterTrigger = beforeCursor.slice(triggerIndex + 1); + if (afterTrigger.includes(' ') || afterTrigger.includes('\n')) { setMentionPopover((prev) => ({ ...prev, isOpen: false })); + setActionPopover((prev) => ({ ...prev, isOpen: false })); return; } // Calculate position for the popover - position it above the chat input const textAreaRect = textArea.getBoundingClientRect(); - setMentionPopover((prev) => ({ - ...prev, - isOpen: true, - position: { - x: textAreaRect.left, - y: textAreaRect.top, // Position at the top of the textarea - }, - query: afterAt, - mentionStart: lastAtIndex, - selectedIndex: 0, // Reset selection when query changes - // filteredFiles will be populated by the MentionPopover component - })); + if (isSlashTrigger) { + // Open action popover for / trigger + setMentionPopover((prev) => ({ ...prev, isOpen: false })); + setActionPopover({ + isOpen: true, + position: { + x: textAreaRect.left, + y: textAreaRect.top, + }, + selectedIndex: 0, + }); + } else { + // Open mention popover for @ trigger (existing functionality) + setActionPopover((prev) => ({ ...prev, isOpen: false })); + setMentionPopover((prev) => ({ + ...prev, + isOpen: true, + position: { + x: textAreaRect.left, + y: textAreaRect.top, + }, + query: afterTrigger, + mentionStart: triggerIndex, + selectedIndex: 0, + })); + } }; const handlePaste = async (evt: React.ClipboardEvent) => { @@ -1165,6 +1200,53 @@ export default function ChatInput({ }, 0); }; + const handleActionButtonClick = (event: React.MouseEvent) => { + const buttonRect = event.currentTarget.getBoundingClientRect(); + + setActionPopover({ + isOpen: true, + position: { + x: buttonRect.left, + y: buttonRect.top, + }, + selectedIndex: 0, + }); + }; + + const handleActionSelect = (actionId: string) => { + // If this was triggered by / symbol, replace it with the action + const currentValue = displayValue; + const cursorPosition = textAreaRef.current?.selectionStart || 0; + const beforeCursor = currentValue.slice(0, cursorPosition); + const lastSlashIndex = beforeCursor.lastIndexOf('/'); + + if (lastSlashIndex !== -1) { + // Check if the / is still active (no space after it) + const afterSlash = beforeCursor.slice(lastSlashIndex + 1); + if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) { + // Replace / with action text + const beforeSlash = currentValue.slice(0, lastSlashIndex); + const afterCursor = currentValue.slice(cursorPosition); + const actionText = `[Action: ${actionId}]`; + const newValue = beforeSlash + actionText + afterCursor; + + setDisplayValue(newValue); + setValue(newValue); + + // Set cursor position after the inserted action text + setTimeout(() => { + if (textAreaRef.current) { + const newCursorPosition = beforeSlash.length + actionText.length; + textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); + } + }, 0); + } + } + + console.log('Action selected:', actionId); + setActionPopover(prev => ({ ...prev, isOpen: false })); + }; + const hasSubmittableContent = displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1586,6 +1668,23 @@ export default function ChatInput({
+ {/* Action button */} + + + + + Quick Actions + +
+ {/* Attach button */} @@ -1661,6 +1760,18 @@ export default function ChatInput({ setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } /> + + setActionPopover((prev) => ({ ...prev, isOpen: false }))} + onSelect={handleActionSelect} + position={actionPopover.position} + selectedIndex={actionPopover.selectedIndex} + onSelectedIndexChange={(index) => + setActionPopover((prev) => ({ ...prev, selectedIndex: index })) + } + />
); diff --git a/ui/desktop/src/components/icons/Action.tsx b/ui/desktop/src/components/icons/Action.tsx new file mode 100644 index 0000000000..2a770dbe82 --- /dev/null +++ b/ui/desktop/src/components/icons/Action.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface ActionProps { + className?: string; + size?: number; +} + +const Action: React.FC = ({ className = '', size = 16 }) => { + return ( + + {/* Plus symbol */} + + + {/* Action indicator - small circle at bottom right */} + + + ); +}; + +export default Action; diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index 556f7dc4bc..e938afb157 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -1,3 +1,4 @@ +import Action from './Action'; import ArrowDown from './ArrowDown'; import ArrowUp from './ArrowUp'; import Attach from './Attach'; @@ -49,6 +50,7 @@ import { Watch5 } from './Watch5'; import { Watch6 } from './Watch6'; export { + Action, ArrowDown, ArrowUp, Attach,