mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user