feat: Add action button with / trigger functionality

- Add Action icon component with plus symbol and action indicator
- Create ActionPopover component with 6 quick actions:
  - Quick Task, Generate Code, Create Document
  - Search Files, Run Command, Settings
- Integrate action button in ChatInput after DirSwitcher with separator
- Add dual trigger system:
  - @ symbol: opens file mention popover (existing)
  - / symbol: opens action popover (new)
- Action selection replaces / with [Action: actionId] text
- Proper TypeScript types and error handling
- Maintains existing @ mention functionality

Components added:
- ActionPopover.tsx - Main popover component
- Action.tsx - Custom action icon
- Updated ChatInput.tsx with action functionality
- Updated icons/index.tsx exports
This commit is contained in:
spencrmartin
2025-09-24 16:01:57 -04:00
parent ad045bc94c
commit e28db8b139
4 changed files with 356 additions and 19 deletions
+194
View File
@@ -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<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Define available actions
const actions: ActionItem[] = [
{
id: 'quick-task',
label: 'Quick Task',
description: 'Create a quick task or reminder',
icon: <Zap size={16} />,
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: <Code size={16} />,
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: <FileText size={16} />,
action: () => {
// TODO: Implement document creation
console.log('Create document action triggered');
},
},
{
id: 'search-files',
label: 'Search Files',
description: 'Search through project files',
icon: <Search size={16} />,
action: () => {
// TODO: Implement file search
console.log('Search files action triggered');
},
},
{
id: 'run-command',
label: 'Run Command',
description: 'Execute a shell command',
icon: <Play size={16} />,
action: () => {
// TODO: Implement command execution
console.log('Run command action triggered');
},
},
{
id: 'settings',
label: 'Settings',
description: 'Open settings and preferences',
icon: <Settings size={16} />,
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 (
<div
ref={popoverRef}
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-80 max-w-md"
style={{
left: position.x,
top: position.y - 10,
transform: 'translateY(-100%)',
}}
>
<div className="p-3">
<div className="mb-2">
<h3 className="text-sm font-medium text-textStandard">Quick Actions</h3>
<p className="text-xs text-textSubtle">Choose an action to perform</p>
</div>
<div ref={listRef} className="space-y-1">
{actions.map((action, index) => (
<div
key={action.id}
onClick={() => 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'
}`}
>
<div className="flex-shrink-0 text-textSubtle">
{action.icon}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-textStandard">{action.label}</div>
<div className="text-xs text-textSubtle">{action.description}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
});
ActionPopover.displayName = 'ActionPopover';
export default ActionPopover;
+130 -19
View File
@@ -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<HTMLTextAreaElement>) => {
@@ -1165,6 +1200,53 @@ export default function ChatInput({
}, 0);
};
const handleActionButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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({
<DirSwitcher className="mr-0" />
<div className="w-px h-4 bg-border-default mx-2" />
{/* Action button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={handleActionButtonClick}
variant="ghost"
size="sm"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
>
<Action className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Quick Actions</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
{/* Attach button */}
<Tooltip>
<TooltipTrigger asChild>
@@ -1661,6 +1760,18 @@ export default function ChatInput({
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
/>
<ActionPopover
ref={actionPopoverRef}
isOpen={actionPopover.isOpen}
onClose={() => setActionPopover((prev) => ({ ...prev, isOpen: false }))}
onSelect={handleActionSelect}
position={actionPopover.position}
selectedIndex={actionPopover.selectedIndex}
onSelectedIndexChange={(index) =>
setActionPopover((prev) => ({ ...prev, selectedIndex: index }))
}
/>
</div>
</div>
);
@@ -0,0 +1,30 @@
import React from 'react';
interface ActionProps {
className?: string;
size?: number;
}
const Action: React.FC<ActionProps> = ({ className = '', size = 16 }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{/* Plus symbol */}
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
{/* Action indicator - small circle at bottom right */}
<circle cx="18" cy="18" r="3" fill="currentColor" stroke="none" opacity="0.8"></circle>
</svg>
);
};
export default Action;
@@ -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,