mirror of
https://github.com/block/goose.git
synced 2026-04-28 11:39:43 +00:00
- Fix import conflicts between local and imported checkSpelling functions - Add @ts-ignore for typo-js import compatibility - Add extensive console logging throughout spell check process - Add fallback spell checker for testing if Typo.js fails - Install @types/typo-js for better TypeScript support - This should help identify why spell check highlighting isn't working
1835 lines
65 KiB
Text
1835 lines
65 KiB
Text
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { FolderKey, ScrollText } from 'lucide-react';
|
|
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, Action } from './icons';
|
|
import { ChatState } from '../types/chatState';
|
|
import debounce from 'lodash/debounce';
|
|
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
|
import { Message } from '../types/message';
|
|
import { DirSwitcher } from './bottom_menu/DirSwitcher';
|
|
import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
|
|
import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection';
|
|
import { AlertType, useAlerts } from './alerts';
|
|
import { useConfig } from './ConfigContext';
|
|
import { useModelAndProvider } from './ModelAndProviderContext';
|
|
import { useWhisper } from '../hooks/useWhisper';
|
|
import { WaveformVisualizer } from './WaveformVisualizer';
|
|
import { toastError } from '../toasts';
|
|
import MentionPopover, { FileItemWithMatch } from './MentionPopover';
|
|
import ActionPopover from './ActionPopover';
|
|
import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react';
|
|
import { useDictationSettings } from '../hooks/useDictationSettings';
|
|
import { useContextManager } from './context_management/ContextManager';
|
|
import { useChatContext } from '../contexts/ChatContext';
|
|
import { COST_TRACKING_ENABLED } from '../updates';
|
|
import { CostTracker } from './bottom_menu/CostTracker';
|
|
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
|
import { RichChatInput, RichChatInputRef } from './RichChatInput';
|
|
import { Recipe } from '../recipe';
|
|
import MessageQueue from './MessageQueue';
|
|
import { detectInterruption } from '../utils/interruptionDetector';
|
|
import { getApiUrl } from '../config';
|
|
|
|
interface QueuedMessage {
|
|
id: string;
|
|
content: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface PastedImage {
|
|
id: string;
|
|
dataUrl: string; // For immediate preview
|
|
filePath?: string; // Path on filesystem after saving
|
|
isLoading: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
// Constants for image handling
|
|
const MAX_IMAGES_PER_MESSAGE = 5;
|
|
const MAX_IMAGE_SIZE_MB = 5;
|
|
|
|
// Constants for token and tool alerts
|
|
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
|
|
const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
|
|
|
|
interface ModelLimit {
|
|
pattern: string;
|
|
context_limit: number;
|
|
}
|
|
|
|
interface ChatInputProps {
|
|
sessionId: string | null;
|
|
handleSubmit: (e: React.FormEvent) => void;
|
|
chatState: ChatState;
|
|
onStop?: () => void;
|
|
commandHistory?: string[]; // Current chat's message history
|
|
initialValue?: string;
|
|
droppedFiles?: DroppedFile[];
|
|
onFilesProcessed?: () => void; // Callback to clear dropped files after processing
|
|
setView: (view: View) => void;
|
|
numTokens?: number;
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
messages?: Message[];
|
|
setMessages: (messages: Message[]) => void;
|
|
sessionCosts?: {
|
|
[key: string]: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalCost: number;
|
|
};
|
|
};
|
|
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
|
disableAnimation?: boolean;
|
|
recipeConfig?: Recipe | null;
|
|
recipeAccepted?: boolean;
|
|
initialPrompt?: string;
|
|
toolCount: number;
|
|
autoSubmit: boolean;
|
|
append?: (message: Message) => void;
|
|
isExtensionsLoading?: boolean;
|
|
}
|
|
|
|
export default function ChatInput({
|
|
sessionId,
|
|
handleSubmit,
|
|
chatState = ChatState.Idle,
|
|
onStop,
|
|
commandHistory = [],
|
|
initialValue = '',
|
|
droppedFiles = [],
|
|
onFilesProcessed,
|
|
setView,
|
|
numTokens,
|
|
inputTokens,
|
|
outputTokens,
|
|
messages = [],
|
|
setMessages,
|
|
disableAnimation = false,
|
|
sessionCosts,
|
|
setIsGoosehintsModalOpen,
|
|
recipeConfig,
|
|
recipeAccepted,
|
|
initialPrompt,
|
|
toolCount,
|
|
autoSubmit = false,
|
|
append,
|
|
isExtensionsLoading = false,
|
|
}: ChatInputProps) {
|
|
const [_value, setValue] = useState(initialValue);
|
|
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
|
|
|
|
// Derived state - chatState != Idle means we're in some form of loading state
|
|
const isLoading = chatState !== ChatState.Idle;
|
|
const wasLoadingRef = useRef(isLoading);
|
|
|
|
// Queue functionality - ephemeral, only exists in memory for this chat instance
|
|
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
|
const queuePausedRef = useRef(false);
|
|
const editingMessageIdRef = useRef<string | null>(null);
|
|
const [lastInterruption, setLastInterruption] = useState<string | null>(null);
|
|
|
|
const { alerts, addAlert, clearAlerts } = useAlerts();
|
|
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
|
|
null
|
|
) as React.RefObject<HTMLDivElement>;
|
|
const { isCompacting, handleManualCompaction } = useContextManager();
|
|
const { getProviders, read } = useConfig();
|
|
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
|
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
|
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
|
|
const [autoCompactThreshold, setAutoCompactThreshold] = useState<number>(0.8); // Default to 80%
|
|
|
|
// Draft functionality - get chat context and global draft context
|
|
// We need to handle the case where ChatInput is used without ChatProvider (e.g., in Hub)
|
|
const chatContext = useChatContext(); // This should always be available now
|
|
const agentIsReady = chatContext === null || chatContext.agentWaitingMessage === null;
|
|
const draftLoadedRef = useRef(false);
|
|
|
|
// Debug logging for draft context
|
|
useEffect(() => {
|
|
// Debug logging removed - draft functionality is working correctly
|
|
}, [chatContext?.contextKey, chatContext?.draft, chatContext]);
|
|
|
|
// Save queue state (paused/interrupted) to storage
|
|
useEffect(() => {
|
|
try {
|
|
window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current));
|
|
} catch (error) {
|
|
console.error('Error saving queue pause state:', error);
|
|
}
|
|
}, [queuedMessages]); // Save when queue changes
|
|
|
|
useEffect(() => {
|
|
try {
|
|
window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption));
|
|
} catch (error) {
|
|
console.error('Error saving queue interruption state:', error);
|
|
}
|
|
}, [lastInterruption]);
|
|
|
|
// Cleanup effect - save final state on component unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
// Save final queue state when component unmounts
|
|
try {
|
|
window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current));
|
|
window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption));
|
|
} catch (error) {
|
|
console.error('Error saving queue state on unmount:', error);
|
|
}
|
|
};
|
|
}, [lastInterruption]); // Include lastInterruption in dependency array
|
|
|
|
// Queue processing
|
|
useEffect(() => {
|
|
if (wasLoadingRef.current && !isLoading && queuedMessages.length > 0) {
|
|
// After an interruption, we should process the interruption message immediately
|
|
// The queue is only truly paused if there was an interruption AND we want to keep it paused
|
|
const shouldProcessQueue = !queuePausedRef.current || lastInterruption;
|
|
|
|
if (shouldProcessQueue) {
|
|
const nextMessage = queuedMessages[0];
|
|
LocalMessageStorage.addMessage(nextMessage.content);
|
|
handleSubmit(
|
|
new CustomEvent('submit', {
|
|
detail: { value: nextMessage.content },
|
|
}) as unknown as React.FormEvent
|
|
);
|
|
setQueuedMessages((prev) => {
|
|
const newQueue = prev.slice(1);
|
|
// If queue becomes empty after processing, clear the paused state
|
|
if (newQueue.length === 0) {
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
}
|
|
return newQueue;
|
|
});
|
|
|
|
// Clear the interruption flag after processing the interruption message
|
|
if (lastInterruption) {
|
|
setLastInterruption(null);
|
|
// Keep the queue paused after sending the interruption message
|
|
// User can manually resume if they want to continue with queued messages
|
|
queuePausedRef.current = true;
|
|
}
|
|
}
|
|
}
|
|
wasLoadingRef.current = isLoading;
|
|
}, [isLoading, queuedMessages, handleSubmit, lastInterruption]);
|
|
const [mentionPopover, setMentionPopover] = useState<{
|
|
isOpen: boolean;
|
|
position: { x: number; y: number };
|
|
query: string;
|
|
mentionStart: number;
|
|
selectedIndex: number;
|
|
}>({
|
|
isOpen: false,
|
|
position: { x: 0, y: 0 },
|
|
query: '',
|
|
mentionStart: -1,
|
|
selectedIndex: 0,
|
|
});
|
|
const [actionPopover, setActionPopover] = useState<{
|
|
isOpen: boolean;
|
|
position: { x: number; y: number };
|
|
selectedIndex: number;
|
|
cursorPosition?: number;
|
|
}>({
|
|
isOpen: false,
|
|
position: { x: 0, y: 0 },
|
|
selectedIndex: 0,
|
|
cursorPosition: 0,
|
|
});
|
|
const actionPopoverRef = useRef<{
|
|
getDisplayActions: () => any[];
|
|
selectAction: (index: number) => void;
|
|
}>(null);
|
|
|
|
// Action pills for visual display
|
|
const mentionPopoverRef = useRef<{
|
|
getDisplayFiles: () => FileItemWithMatch[];
|
|
selectFile: (index: number) => void;
|
|
}>(null);
|
|
|
|
// Whisper hook for voice dictation
|
|
const {
|
|
isRecording,
|
|
isTranscribing,
|
|
canUseDictation,
|
|
audioContext,
|
|
analyser,
|
|
startRecording,
|
|
stopRecording,
|
|
recordingDuration,
|
|
estimatedSize,
|
|
} = useWhisper({
|
|
onTranscription: (text) => {
|
|
// Append transcribed text to the current input
|
|
const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text;
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
textAreaRef.current?.focus();
|
|
},
|
|
onError: (error) => {
|
|
toastError({
|
|
title: 'Dictation Error',
|
|
msg: error.message,
|
|
});
|
|
},
|
|
onSizeWarning: (sizeMB) => {
|
|
toastError({
|
|
title: 'Recording Size Warning',
|
|
msg: `Recording is ${sizeMB.toFixed(1)}MB. Maximum size is 25MB.`,
|
|
});
|
|
},
|
|
});
|
|
|
|
// Get dictation settings to check configuration status
|
|
const { settings: dictationSettings } = useDictationSettings();
|
|
|
|
// Update internal value when initialValue changes
|
|
useEffect(() => {
|
|
setValue(initialValue);
|
|
setDisplayValue(initialValue);
|
|
|
|
// Reset draft loaded flag when initialValue changes
|
|
draftLoadedRef.current = false;
|
|
|
|
// Use a functional update to get the current pastedImages
|
|
// and perform cleanup. This avoids needing pastedImages in the deps.
|
|
setPastedImages((currentPastedImages) => {
|
|
currentPastedImages.forEach((img) => {
|
|
if (img.filePath) {
|
|
window.electron.deleteTempFile(img.filePath);
|
|
}
|
|
});
|
|
return []; // Return a new empty array
|
|
});
|
|
|
|
// Reset history index when input is cleared
|
|
setHistoryIndex(-1);
|
|
setIsInGlobalHistory(false);
|
|
setHasUserTyped(false);
|
|
}, [initialValue]); // Keep only initialValue as a dependency
|
|
|
|
// Handle recipe prompt updates
|
|
useEffect(() => {
|
|
// If recipe is accepted and we have an initial prompt, and no messages yet, and we haven't set it before
|
|
if (recipeAccepted && initialPrompt && messages.length === 0) {
|
|
setDisplayValue(initialPrompt);
|
|
setValue(initialPrompt);
|
|
setTimeout(() => {
|
|
textAreaRef.current?.focus();
|
|
}, 0);
|
|
}
|
|
}, [recipeAccepted, initialPrompt, messages.length]);
|
|
|
|
// Draft functionality - load draft if no initial value or recipe
|
|
useEffect(() => {
|
|
// Reset draft loaded flag when context changes
|
|
draftLoadedRef.current = false;
|
|
}, [chatContext?.contextKey]);
|
|
|
|
useEffect(() => {
|
|
// Only load draft once and if conditions are met
|
|
if (!initialValue && !recipeConfig && !draftLoadedRef.current && chatContext) {
|
|
const draftText = chatContext.draft || '';
|
|
|
|
if (draftText) {
|
|
setDisplayValue(draftText);
|
|
setValue(draftText);
|
|
}
|
|
|
|
// Always mark as loaded after checking, regardless of whether we found a draft
|
|
draftLoadedRef.current = true;
|
|
}
|
|
}, [chatContext, initialValue, recipeConfig]);
|
|
|
|
// Save draft when user types (debounced)
|
|
const debouncedSaveDraft = useMemo(
|
|
() =>
|
|
debounce((value: string) => {
|
|
if (chatContext && chatContext.setDraft) {
|
|
chatContext.setDraft(value);
|
|
}
|
|
}, 500), // Save draft after 500ms of no typing
|
|
[chatContext]
|
|
);
|
|
|
|
// State to track if the IME is composing (i.e., in the middle of Japanese IME input)
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [savedInput, setSavedInput] = useState('');
|
|
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
|
|
const [hasUserTyped, setHasUserTyped] = useState(false);
|
|
const textAreaRef = useRef<RichChatInputRef>(null);
|
|
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
|
const [didAutoSubmit, setDidAutoSubmit] = useState<boolean>(false);
|
|
|
|
// Use shared file drop hook for ChatInput
|
|
const {
|
|
droppedFiles: localDroppedFiles,
|
|
setDroppedFiles: setLocalDroppedFiles,
|
|
handleDrop: handleLocalDrop,
|
|
handleDragOver: handleLocalDragOver,
|
|
} = useFileDrop();
|
|
|
|
// Merge local dropped files with parent dropped files
|
|
const allDroppedFiles = useMemo(
|
|
() => [...droppedFiles, ...localDroppedFiles],
|
|
[droppedFiles, localDroppedFiles]
|
|
);
|
|
|
|
const handleRemoveDroppedFile = (idToRemove: string) => {
|
|
// Remove from local dropped files
|
|
setLocalDroppedFiles((prev) => prev.filter((file) => file.id !== idToRemove));
|
|
|
|
// If it's from parent, call the parent's callback
|
|
if (onFilesProcessed && droppedFiles.some((file) => file.id === idToRemove)) {
|
|
onFilesProcessed();
|
|
}
|
|
};
|
|
|
|
const handleRemovePastedImage = (idToRemove: string) => {
|
|
const imageToRemove = pastedImages.find((img) => img.id === idToRemove);
|
|
if (imageToRemove?.filePath) {
|
|
window.electron.deleteTempFile(imageToRemove.filePath);
|
|
}
|
|
setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove));
|
|
};
|
|
|
|
const handleRetryImageSave = async (imageId: string) => {
|
|
const imageToRetry = pastedImages.find((img) => img.id === imageId);
|
|
if (!imageToRetry || !imageToRetry.dataUrl) return;
|
|
|
|
// Set the image to loading state
|
|
setPastedImages((prev) =>
|
|
prev.map((img) => (img.id === imageId ? { ...img, isLoading: true, error: undefined } : img))
|
|
);
|
|
|
|
try {
|
|
const result = await window.electron.saveDataUrlToTemp(imageToRetry.dataUrl, imageId);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === result.id
|
|
? { ...img, filePath: result.filePath, error: result.error, isLoading: false }
|
|
: img
|
|
)
|
|
);
|
|
} catch (err) {
|
|
console.error('Error retrying image save:', err);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === imageId
|
|
? { ...img, error: 'Failed to save image via Electron.', isLoading: false }
|
|
: img
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, []);
|
|
|
|
// Load model limits from the API
|
|
const getModelLimits = async () => {
|
|
try {
|
|
const response = await read('model-limits', false);
|
|
if (response) {
|
|
// The response is already parsed, no need for JSON.parse
|
|
return response as ModelLimit[];
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching model limits:', err);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// Helper function to find model limit using pattern matching
|
|
const findModelLimit = (modelName: string, modelLimits: ModelLimit[]): number | null => {
|
|
if (!modelName) return null;
|
|
const matchingLimit = modelLimits.find((limit) =>
|
|
modelName.toLowerCase().includes(limit.pattern.toLowerCase())
|
|
);
|
|
return matchingLimit ? matchingLimit.context_limit : null;
|
|
};
|
|
|
|
// Load providers and get current model's token limit
|
|
const loadProviderDetails = async () => {
|
|
try {
|
|
// Reset token limit loaded state
|
|
setIsTokenLimitLoaded(false);
|
|
|
|
// Get current model and provider first to avoid unnecessary provider fetches
|
|
const { model, provider } = await getCurrentModelAndProvider();
|
|
if (!model || !provider) {
|
|
console.log('No model or provider found');
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
|
|
const providers = await getProviders(true);
|
|
|
|
// Find the provider details for the current provider
|
|
const currentProvider = providers.find((p) => p.name === provider);
|
|
if (currentProvider?.metadata?.known_models) {
|
|
// Find the model's token limit from the backend response
|
|
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
|
|
if (modelConfig?.context_limit) {
|
|
setTokenLimit(modelConfig.context_limit);
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: Use pattern matching logic if no exact model match was found
|
|
const modelLimit = await getModelLimits();
|
|
const fallbackLimit = findModelLimit(model as string, modelLimit);
|
|
if (fallbackLimit !== null) {
|
|
setTokenLimit(fallbackLimit);
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// If no match found, use the default model limit
|
|
setTokenLimit(TOKEN_LIMIT_DEFAULT);
|
|
setIsTokenLimitLoaded(true);
|
|
} catch (err) {
|
|
console.error('Error loading providers or token limit:', err);
|
|
// Set default limit on error
|
|
setTokenLimit(TOKEN_LIMIT_DEFAULT);
|
|
setIsTokenLimitLoaded(true);
|
|
}
|
|
};
|
|
|
|
// Initial load and refresh when model changes
|
|
useEffect(() => {
|
|
loadProviderDetails();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentModel, currentProvider]);
|
|
|
|
// Load auto-compact threshold
|
|
const loadAutoCompactThreshold = useCallback(async () => {
|
|
try {
|
|
const secretKey = await window.electron.getSecretKey();
|
|
const response = await fetch(getApiUrl('/config/read'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Secret-Key': secretKey,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
key: 'GOOSE_AUTO_COMPACT_THRESHOLD',
|
|
is_secret: false,
|
|
}),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Loaded auto-compact threshold from config:', data);
|
|
if (data !== undefined && data !== null) {
|
|
setAutoCompactThreshold(data);
|
|
console.log('Set auto-compact threshold to:', data);
|
|
}
|
|
} else {
|
|
console.error('Failed to fetch auto-compact threshold, status:', response.status);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching auto-compact threshold:', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadAutoCompactThreshold();
|
|
}, [loadAutoCompactThreshold]);
|
|
|
|
// Listen for threshold change events from AlertBox
|
|
useEffect(() => {
|
|
const handleThresholdChange = (event: CustomEvent<{ threshold: number }>) => {
|
|
setAutoCompactThreshold(event.detail.threshold);
|
|
};
|
|
|
|
// Type assertion to handle the mismatch between CustomEvent and EventListener
|
|
const eventListener = handleThresholdChange as (event: globalThis.Event) => void;
|
|
window.addEventListener('autoCompactThresholdChanged', eventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener('autoCompactThresholdChanged', eventListener);
|
|
};
|
|
}, []);
|
|
|
|
// Handle tool count alerts and token usage
|
|
useEffect(() => {
|
|
clearAlerts();
|
|
|
|
// Show alert when either there is registered token usage, or we know the limit
|
|
if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) {
|
|
// in these conditions we want it to be present but disabled
|
|
const compactButtonDisabled = !numTokens || isCompacting;
|
|
|
|
addAlert({
|
|
type: AlertType.Info,
|
|
message: 'Context window',
|
|
progress: {
|
|
current: numTokens || 0,
|
|
total: tokenLimit,
|
|
},
|
|
showCompactButton: true,
|
|
compactButtonDisabled,
|
|
onCompact: () => {
|
|
// Hide the alert popup by dispatching a custom event that the popover can listen to
|
|
// Importantly, this leaves the alert so the dot still shows up, but hides the popover
|
|
window.dispatchEvent(new CustomEvent('hide-alert-popover'));
|
|
handleManualCompaction(messages, setMessages, append);
|
|
},
|
|
compactIcon: <ScrollText size={12} />,
|
|
autoCompactThreshold: autoCompactThreshold,
|
|
});
|
|
}
|
|
|
|
// Add tool count alert if we have the data
|
|
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
|
|
addAlert({
|
|
type: AlertType.Warning,
|
|
message: `Too many tools can degrade performance.\nTool count: ${toolCount} (recommend: ${TOOLS_MAX_SUGGESTED})`,
|
|
action: {
|
|
text: 'View extensions',
|
|
onClick: () => setView('extensions'),
|
|
},
|
|
autoShow: false, // Don't auto-show tool count warnings
|
|
});
|
|
}
|
|
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
numTokens,
|
|
toolCount,
|
|
tokenLimit,
|
|
isTokenLimitLoaded,
|
|
addAlert,
|
|
isCompacting,
|
|
clearAlerts,
|
|
autoCompactThreshold,
|
|
]);
|
|
|
|
// Cleanup effect for component unmount - prevent memory leaks
|
|
useEffect(() => {
|
|
return () => {
|
|
// Clear any pending timeouts from image processing
|
|
setPastedImages((currentImages) => {
|
|
currentImages.forEach((img) => {
|
|
if (img.filePath) {
|
|
try {
|
|
window.electron.deleteTempFile(img.filePath);
|
|
} catch (error) {
|
|
console.error('Error deleting temp file:', error);
|
|
}
|
|
}
|
|
});
|
|
return [];
|
|
});
|
|
|
|
// Clear all tracked timeouts
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const timeouts = timeoutRefsRef.current;
|
|
timeouts.forEach((timeoutId) => {
|
|
window.clearTimeout(timeoutId);
|
|
});
|
|
timeouts.clear();
|
|
|
|
// Clear alerts to prevent memory leaks
|
|
clearAlerts();
|
|
};
|
|
}, [clearAlerts]);
|
|
|
|
const maxHeight = 10 * 24;
|
|
|
|
// Immediate function to update actual value - no debounce for better responsiveness
|
|
const updateValue = React.useCallback((value: string) => {
|
|
setValue(value);
|
|
}, []);
|
|
|
|
const debouncedAutosize = useMemo(
|
|
() =>
|
|
debounce((element: HTMLElement) => {
|
|
element.style.height = '0px'; // Reset height
|
|
const scrollHeight = element.scrollHeight;
|
|
element.style.height = Math.min(scrollHeight, maxHeight) + 'px';
|
|
}, 50),
|
|
[maxHeight]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
const element = (textAreaRef.current as any).contentRef?.current; if (element) { debouncedAutosize(element); }
|
|
}
|
|
}, [debouncedAutosize, displayValue]);
|
|
|
|
// Reset textarea height when displayValue is empty
|
|
useEffect(() => {
|
|
if (textAreaRef.current && displayValue === '') {
|
|
const element = (textAreaRef.current as any)?.contentRef?.current; if (element && element.style) { element.style.height = 'auto'; }
|
|
}
|
|
}, [displayValue]);
|
|
|
|
// const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
// const val = evt.target.value;
|
|
// const cursorPosition = evt.target.selectionStart;
|
|
//
|
|
// setDisplayValue(val); // Update display immediately
|
|
// updateValue(val); // Update actual value immediately for better responsiveness
|
|
// debouncedSaveDraft(val); // Save draft with debounce
|
|
// // Mark that the user has typed something
|
|
// setHasUserTyped(true);
|
|
//
|
|
// // Check for @ mention
|
|
// checkForMention(val, cursorPosition, evt.target);
|
|
// };
|
|
|
|
const checkForMention = (text: string, cursorPosition: number, textArea: any) => {
|
|
// 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 (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 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();
|
|
|
|
if (isSlashTrigger) {
|
|
// Open action popover for / trigger
|
|
setMentionPopover((prev) => ({ ...prev, isOpen: false }));
|
|
setActionPopover({
|
|
isOpen: true,
|
|
position: {
|
|
x: textAreaRect.left,
|
|
y: textAreaRect.top,
|
|
},
|
|
selectedIndex: 0,
|
|
cursorPosition: cursorPosition,
|
|
});
|
|
} 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<HTMLDivElement>) => {
|
|
const files = Array.from(evt.clipboardData.files || []);
|
|
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
|
|
|
if (imageFiles.length === 0) return;
|
|
|
|
// Check if adding these images would exceed the limit
|
|
if (pastedImages.length + imageFiles.length > MAX_IMAGES_PER_MESSAGE) {
|
|
// Show error message to user
|
|
setPastedImages((prev) => [
|
|
...prev,
|
|
{
|
|
id: `error-${Date.now()}`,
|
|
dataUrl: '',
|
|
isLoading: false,
|
|
error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed. Currently have ${pastedImages.length}.`,
|
|
},
|
|
]);
|
|
|
|
// Remove the error message after 5 seconds with cleanup tracking
|
|
const timeoutId = setTimeout(() => {
|
|
setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-')));
|
|
timeoutRefsRef.current.delete(timeoutId);
|
|
}, 5000);
|
|
timeoutRefsRef.current.add(timeoutId);
|
|
|
|
return;
|
|
}
|
|
|
|
evt.preventDefault();
|
|
|
|
// Process each image file
|
|
const newImages: PastedImage[] = [];
|
|
|
|
for (const file of imageFiles) {
|
|
// Check individual file size before processing
|
|
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
|
|
const errorId = `error-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
newImages.push({
|
|
id: errorId,
|
|
dataUrl: '',
|
|
isLoading: false,
|
|
error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`,
|
|
});
|
|
|
|
// Remove the error message after 5 seconds with cleanup tracking
|
|
const timeoutId = setTimeout(() => {
|
|
setPastedImages((prev) => prev.filter((img) => img.id !== errorId));
|
|
timeoutRefsRef.current.delete(timeoutId);
|
|
}, 5000);
|
|
timeoutRefsRef.current.add(timeoutId);
|
|
|
|
continue;
|
|
}
|
|
|
|
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
// Add the image with loading state
|
|
newImages.push({
|
|
id: imageId,
|
|
dataUrl: '',
|
|
isLoading: true,
|
|
});
|
|
|
|
// Process the image asynchronously
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
const dataUrl = e.target?.result as string;
|
|
if (dataUrl) {
|
|
// Update the image with the data URL
|
|
setPastedImages((prev) =>
|
|
prev.map((img) => (img.id === imageId ? { ...img, dataUrl, isLoading: true } : img))
|
|
);
|
|
|
|
try {
|
|
const result = await window.electron.saveDataUrlToTemp(dataUrl, imageId);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === result.id
|
|
? { ...img, filePath: result.filePath, error: result.error, isLoading: false }
|
|
: img
|
|
)
|
|
);
|
|
} catch (err) {
|
|
console.error('Error saving pasted image:', err);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === imageId
|
|
? { ...img, error: 'Failed to save image via Electron.', isLoading: false }
|
|
: img
|
|
)
|
|
);
|
|
}
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
console.error('Failed to read image file:', file.name);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === imageId
|
|
? { ...img, error: 'Failed to read image file.', isLoading: false }
|
|
: img
|
|
)
|
|
);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// Add all new images to the existing list
|
|
setPastedImages((prev) => [...prev, ...newImages]);
|
|
};
|
|
|
|
// Cleanup debounced functions on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
debouncedAutosize.cancel?.();
|
|
debouncedSaveDraft.cancel?.();
|
|
};
|
|
}, [debouncedAutosize, debouncedSaveDraft]);
|
|
|
|
// Handlers for composition events, which are crucial for proper IME behavior
|
|
const handleCompositionStart = () => {
|
|
setIsComposing(true);
|
|
};
|
|
|
|
const handleCompositionEnd = () => {
|
|
setIsComposing(false);
|
|
};
|
|
|
|
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const isUp = evt.key === 'ArrowUp';
|
|
const isDown = evt.key === 'ArrowDown';
|
|
|
|
// Only handle up/down keys with Cmd/Ctrl modifier
|
|
if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
// Only prevent history navigation if the user has actively typed something
|
|
// This allows history navigation when text is populated from history or other sources
|
|
// but prevents it when the user is actively editing text
|
|
if (hasUserTyped && displayValue.trim() !== '') {
|
|
return;
|
|
}
|
|
|
|
evt.preventDefault();
|
|
|
|
// Get global history once to avoid multiple calls
|
|
const globalHistory = LocalMessageStorage.getRecentMessages() || [];
|
|
|
|
// Save current input if we're just starting to navigate history
|
|
if (historyIndex === -1) {
|
|
setSavedInput(displayValue || '');
|
|
setIsInGlobalHistory(commandHistory.length === 0);
|
|
}
|
|
|
|
// Determine which history we're using
|
|
const currentHistory = isInGlobalHistory ? globalHistory : commandHistory;
|
|
let newIndex = historyIndex;
|
|
let newValue = '';
|
|
|
|
// Handle navigation
|
|
if (isUp) {
|
|
// Moving up through history
|
|
if (newIndex < currentHistory.length - 1) {
|
|
// Still have items in current history
|
|
newIndex = historyIndex + 1;
|
|
newValue = currentHistory[newIndex];
|
|
} else if (!isInGlobalHistory && globalHistory.length > 0) {
|
|
// Switch to global history
|
|
setIsInGlobalHistory(true);
|
|
newIndex = 0;
|
|
newValue = globalHistory[newIndex];
|
|
}
|
|
} else {
|
|
// Moving down through history
|
|
if (newIndex > 0) {
|
|
// Still have items in current history
|
|
newIndex = historyIndex - 1;
|
|
newValue = currentHistory[newIndex];
|
|
} else if (isInGlobalHistory && commandHistory.length > 0) {
|
|
// Switch to chat history
|
|
setIsInGlobalHistory(false);
|
|
newIndex = commandHistory.length - 1;
|
|
newValue = commandHistory[newIndex];
|
|
} else {
|
|
// Return to original input
|
|
newIndex = -1;
|
|
newValue = savedInput;
|
|
}
|
|
}
|
|
|
|
// Update display if we have a new value
|
|
if (newIndex !== historyIndex) {
|
|
setHistoryIndex(newIndex);
|
|
if (newIndex === -1) {
|
|
setDisplayValue(savedInput || '');
|
|
setValue(savedInput || '');
|
|
} else {
|
|
setDisplayValue(newValue || '');
|
|
setValue(newValue || '');
|
|
}
|
|
// Reset hasUserTyped when we populate from history
|
|
setHasUserTyped(false);
|
|
}
|
|
};
|
|
|
|
// Helper function to handle interruption and queue logic when loading
|
|
const handleInterruptionAndQueue = () => {
|
|
if (!isLoading || !displayValue.trim()) {
|
|
return false; // Return false if no action was taken
|
|
}
|
|
|
|
const interruptionMatch = detectInterruption(displayValue.trim());
|
|
|
|
if (interruptionMatch && interruptionMatch.shouldInterrupt) {
|
|
setLastInterruption(interruptionMatch.matchedText);
|
|
if (onStop) onStop();
|
|
queuePausedRef.current = true;
|
|
|
|
// For interruptions, we need to queue the message to be sent after the stop completes
|
|
// rather than trying to send it immediately while the system is still loading
|
|
const interruptionMessage = {
|
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
|
content: displayValue.trim(),
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
// Add the interruption message to the front of the queue so it gets sent first
|
|
setQueuedMessages((prev) => [interruptionMessage, ...prev]);
|
|
|
|
setDisplayValue('');
|
|
setValue('');
|
|
return true; // Return true if interruption was handled
|
|
}
|
|
|
|
const newMessage = {
|
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
|
content: displayValue.trim(),
|
|
timestamp: Date.now(),
|
|
};
|
|
setQueuedMessages((prev) => {
|
|
const newQueue = [...prev, newMessage];
|
|
// If adding to an empty queue, reset the paused state
|
|
if (prev.length === 0) {
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
}
|
|
return newQueue;
|
|
});
|
|
setDisplayValue('');
|
|
setValue('');
|
|
return true; // Return true if message was queued
|
|
};
|
|
|
|
const canSubmit =
|
|
!isLoading &&
|
|
!isCompacting &&
|
|
agentIsReady &&
|
|
(displayValue.trim() ||
|
|
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
|
|
allDroppedFiles.some((file) => !file.error && !file.isLoading));
|
|
|
|
const performSubmit = useCallback(
|
|
(text?: string) => {
|
|
const validPastedImageFilesPaths = pastedImages
|
|
.filter((img) => img.filePath && !img.error && !img.isLoading)
|
|
.map((img) => img.filePath as string);
|
|
// Get paths from all dropped files (both parent and local)
|
|
const droppedFilePaths = allDroppedFiles
|
|
.filter((file) => !file.error && !file.isLoading)
|
|
.map((file) => file.path);
|
|
|
|
let textToSend = text ?? displayValue.trim();
|
|
|
|
// Combine pasted images and dropped files
|
|
const allFilePaths = [...validPastedImageFilesPaths, ...droppedFilePaths];
|
|
if (allFilePaths.length > 0) {
|
|
const pathsString = allFilePaths.join(' ');
|
|
textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString;
|
|
}
|
|
|
|
if (textToSend) {
|
|
if (displayValue.trim()) {
|
|
LocalMessageStorage.addMessage(displayValue);
|
|
} else if (allFilePaths.length > 0) {
|
|
LocalMessageStorage.addMessage(allFilePaths.join(' '));
|
|
}
|
|
|
|
handleSubmit(
|
|
new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent
|
|
);
|
|
|
|
// Auto-resume queue after sending a NON-interruption message (if it was paused due to interruption)
|
|
if (
|
|
queuePausedRef.current &&
|
|
lastInterruption &&
|
|
textToSend &&
|
|
!detectInterruption(textToSend)
|
|
) {
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
}
|
|
|
|
setDisplayValue('');
|
|
setValue('');
|
|
setPastedImages([]);
|
|
setHistoryIndex(-1);
|
|
setSavedInput('');
|
|
setIsInGlobalHistory(false);
|
|
setHasUserTyped(false);
|
|
|
|
// Clear draft when message is sent
|
|
if (chatContext && chatContext.clearDraft) {
|
|
chatContext.clearDraft();
|
|
}
|
|
|
|
// Clear selected actions when message is sent
|
|
// Actions cleared when message sent
|
|
|
|
// Clear both parent and local dropped files after processing
|
|
if (onFilesProcessed && droppedFiles.length > 0) {
|
|
onFilesProcessed();
|
|
}
|
|
if (localDroppedFiles.length > 0) {
|
|
setLocalDroppedFiles([]);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
allDroppedFiles,
|
|
chatContext,
|
|
displayValue,
|
|
droppedFiles.length,
|
|
handleSubmit,
|
|
lastInterruption,
|
|
localDroppedFiles.length,
|
|
onFilesProcessed,
|
|
pastedImages,
|
|
setLocalDroppedFiles,
|
|
]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!!autoSubmit && !didAutoSubmit) {
|
|
setDidAutoSubmit(true);
|
|
performSubmit(initialValue);
|
|
}
|
|
}, [autoSubmit, didAutoSubmit, initialValue, performSubmit]);
|
|
|
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
|
// If mention popover is open, handle arrow keys and enter
|
|
if (mentionPopover.isOpen && mentionPopoverRef.current) {
|
|
if (evt.key === 'ArrowDown') {
|
|
evt.preventDefault();
|
|
const displayFiles = mentionPopoverRef.current.getDisplayFiles();
|
|
const maxIndex = Math.max(0, displayFiles.length - 1);
|
|
setMentionPopover((prev) => ({
|
|
...prev,
|
|
selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex),
|
|
}));
|
|
return;
|
|
}
|
|
if (evt.key === 'ArrowUp') {
|
|
evt.preventDefault();
|
|
setMentionPopover((prev) => ({
|
|
...prev,
|
|
selectedIndex: Math.max(prev.selectedIndex - 1, 0),
|
|
}));
|
|
return;
|
|
}
|
|
if (evt.key === 'Enter') {
|
|
evt.preventDefault();
|
|
mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex);
|
|
return;
|
|
}
|
|
if (evt.key === 'Escape') {
|
|
evt.preventDefault();
|
|
setMentionPopover((prev) => ({ ...prev, isOpen: false }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle history navigation first
|
|
handleHistoryNavigation(evt);
|
|
|
|
if (evt.key === 'Enter') {
|
|
// should not trigger submit on Enter if it's composing (IME input in progress) or shift/alt(option) is pressed
|
|
if (evt.shiftKey || isComposing) {
|
|
// Allow line break for Shift+Enter, or during IME composition
|
|
return;
|
|
}
|
|
|
|
if (evt.altKey) {
|
|
const newValue = displayValue + '\n';
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
return;
|
|
}
|
|
|
|
evt.preventDefault();
|
|
|
|
// Handle interruption and queue logic
|
|
if (handleInterruptionAndQueue()) {
|
|
return;
|
|
}
|
|
|
|
if (canSubmit) {
|
|
performSubmit();
|
|
}
|
|
}
|
|
};
|
|
|
|
const onFormSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const canSubmit =
|
|
!isLoading &&
|
|
!isCompacting &&
|
|
agentIsReady &&
|
|
(displayValue.trim() ||
|
|
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
|
|
allDroppedFiles.some((file) => !file.error && !file.isLoading));
|
|
if (canSubmit) {
|
|
performSubmit();
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = async () => {
|
|
const path = await window.electron.selectFileOrDirectory();
|
|
if (path) {
|
|
const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path;
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
textAreaRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
const handleMentionFileSelect = (filePath: string) => {
|
|
console.log('📁 handleMentionFileSelect called with:', filePath);
|
|
|
|
// Extract just the filename from the full path for the pill
|
|
const fileName = filePath.split('/').pop() || filePath;
|
|
console.log('📁 Extracted filename:', fileName);
|
|
|
|
// Create @filename format for pill detection
|
|
const mentionText = `@${fileName}`;
|
|
console.log('📁 Creating mention text:', mentionText);
|
|
|
|
// Replace the @ mention with @filename format
|
|
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
|
|
const afterMention = displayValue.slice(
|
|
mentionPopover.mentionStart + 1 + mentionPopover.query.length
|
|
);
|
|
const newValue = `${beforeMention}${mentionText} ${afterMention}`;
|
|
|
|
console.log('📁 New value will be:', newValue);
|
|
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
setMentionPopover((prev) => ({ ...prev, isOpen: false }));
|
|
textAreaRef.current?.focus();
|
|
|
|
// Set cursor position after the inserted mention and space
|
|
const newCursorPosition = beforeMention.length + mentionText.length + 1;
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const handleActionButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
const buttonRect = event.currentTarget.getBoundingClientRect();
|
|
|
|
setActionPopover({
|
|
isOpen: true,
|
|
position: {
|
|
x: buttonRect.left,
|
|
y: buttonRect.top,
|
|
},
|
|
selectedIndex: 0,
|
|
cursorPosition: textAreaRef.current?.getBoundingClientRect ? 0 : 0, // Will be set by RichChatInput
|
|
});
|
|
};
|
|
|
|
// Helper function to get action info
|
|
const getActionInfo = (actionId: string) => {
|
|
const actionMap = {
|
|
'quick-task': { label: 'Quick Task', icon: <Zap size={12} /> },
|
|
'generate-code': { label: 'Generate Code', icon: <Code size={12} /> },
|
|
'create-document': { label: 'Create Document', icon: <FileText size={12} /> },
|
|
'search-files': { label: 'Search Files', icon: <Search size={12} /> },
|
|
'run-command': { label: 'Run Command', icon: <Play size={12} /> },
|
|
'settings': { label: 'Settings', icon: <Settings size={12} /> },
|
|
};
|
|
return actionMap[actionId as keyof typeof actionMap] || { label: actionId, icon: <Zap size={12} /> };
|
|
};
|
|
|
|
const handleActionSelect = (actionId: string) => {
|
|
const actionInfo = getActionInfo(actionId);
|
|
|
|
// Get current cursor position from the RichChatInput
|
|
const currentValue = displayValue;
|
|
const cursorPosition = actionPopover.cursorPosition || 0;
|
|
const beforeCursor = currentValue.slice(0, cursorPosition);
|
|
const afterCursor = currentValue.slice(cursorPosition);
|
|
const lastSlashIndex = beforeCursor.lastIndexOf('/');
|
|
|
|
if (lastSlashIndex !== -1) {
|
|
const afterSlash = beforeCursor.slice(lastSlashIndex + 1);
|
|
// Check if we're still in the same "word" after the slash
|
|
if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) {
|
|
// Replace the /query with [Action] text
|
|
const beforeSlash = currentValue.slice(0, lastSlashIndex);
|
|
const actionText = `[${actionInfo.label}]`;
|
|
const newValue = beforeSlash + actionText + " " + afterCursor;
|
|
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
|
|
// Set cursor position after the action text and space
|
|
const newCursorPosition = lastSlashIndex + actionText.length + 1;
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
console.log('Action selected:', actionId, 'at position:', cursorPosition);
|
|
setActionPopover(prev => ({ ...prev, isOpen: false }));
|
|
};
|
|
|
|
|
|
const hasSubmittableContent =
|
|
displayValue.trim() ||
|
|
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
|
|
allDroppedFiles.some((file) => !file.error && !file.isLoading);
|
|
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
|
|
const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading);
|
|
|
|
const isSubmitButtonDisabled =
|
|
!hasSubmittableContent ||
|
|
isAnyImageLoading ||
|
|
isAnyDroppedFileLoading ||
|
|
isRecording ||
|
|
isTranscribing ||
|
|
isCompacting ||
|
|
!agentIsReady ||
|
|
isExtensionsLoading;
|
|
|
|
const isUserInputDisabled =
|
|
isAnyImageLoading ||
|
|
isAnyDroppedFileLoading ||
|
|
isRecording ||
|
|
isTranscribing ||
|
|
isCompacting ||
|
|
!agentIsReady ||
|
|
isExtensionsLoading;
|
|
|
|
// Queue management functions - no storage persistence, only in-memory
|
|
const handleRemoveQueuedMessage = (messageId: string) => {
|
|
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
|
};
|
|
|
|
const handleClearQueue = () => {
|
|
setQueuedMessages([]);
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
};
|
|
|
|
const handleReorderMessages = (reorderedMessages: QueuedMessage[]) => {
|
|
setQueuedMessages(reorderedMessages);
|
|
};
|
|
|
|
const handleEditMessage = (messageId: string, newContent: string) => {
|
|
setQueuedMessages((prev) =>
|
|
prev.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg))
|
|
);
|
|
};
|
|
|
|
const handleStopAndSend = (messageId: string) => {
|
|
const messageToSend = queuedMessages.find((msg) => msg.id === messageId);
|
|
if (!messageToSend) return;
|
|
|
|
// Stop current processing and temporarily pause queue to prevent double-send
|
|
if (onStop) onStop();
|
|
const wasPaused = queuePausedRef.current;
|
|
queuePausedRef.current = true;
|
|
|
|
// Remove the message from queue and send it immediately
|
|
setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
|
LocalMessageStorage.addMessage(messageToSend.content);
|
|
handleSubmit(
|
|
new CustomEvent('submit', {
|
|
detail: { value: messageToSend.content },
|
|
}) as unknown as React.FormEvent
|
|
);
|
|
|
|
// Restore previous pause state after a brief delay to prevent race condition
|
|
setTimeout(() => {
|
|
queuePausedRef.current = wasPaused;
|
|
}, 100);
|
|
};
|
|
|
|
const handleResumeQueue = () => {
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
if (!isLoading && queuedMessages.length > 0) {
|
|
const nextMessage = queuedMessages[0];
|
|
LocalMessageStorage.addMessage(nextMessage.content);
|
|
handleSubmit(
|
|
new CustomEvent('submit', {
|
|
detail: { value: nextMessage.content },
|
|
}) as unknown as React.FormEvent
|
|
);
|
|
setQueuedMessages((prev) => {
|
|
const newQueue = prev.slice(1);
|
|
// If queue becomes empty after processing, clear the paused state
|
|
if (newQueue.length === 0) {
|
|
queuePausedRef.current = false;
|
|
setLastInterruption(null);
|
|
}
|
|
return newQueue;
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`flex flex-col relative h-auto p-4 transition-colors ${
|
|
disableAnimation ? '' : 'page-transition'
|
|
} ${
|
|
isFocused
|
|
? 'border-borderProminent hover:border-borderProminent'
|
|
: 'border-borderSubtle hover:border-borderStandard'
|
|
} bg-background-default z-10 rounded-t-2xl`}
|
|
data-drop-zone="true"
|
|
onDrop={handleLocalDrop}
|
|
onDragOver={handleLocalDragOver}
|
|
>
|
|
{/* Message Queue Display */}
|
|
{queuedMessages.length > 0 && (
|
|
<MessageQueue
|
|
queuedMessages={queuedMessages}
|
|
onRemoveMessage={handleRemoveQueuedMessage}
|
|
onClearQueue={handleClearQueue}
|
|
onStopAndSend={handleStopAndSend}
|
|
onReorderMessages={handleReorderMessages}
|
|
onEditMessage={handleEditMessage}
|
|
onTriggerQueueProcessing={handleResumeQueue}
|
|
editingMessageIdRef={editingMessageIdRef}
|
|
isPaused={queuePausedRef.current}
|
|
className="border-b border-borderSubtle"
|
|
/>
|
|
)}
|
|
{/* Input row with inline action buttons wrapped in form */}
|
|
<form onSubmit={onFormSubmit} className="relative flex items-end">
|
|
<div className="relative flex-1">
|
|
|
|
|
|
<RichChatInput
|
|
data-testid="chat-input"
|
|
autoFocus
|
|
placeholder={isRecording ? '' : '⌘↑/⌘↓ to navigate messages'}
|
|
value={displayValue}
|
|
onChange={(newValue, cursorPos) => {
|
|
setDisplayValue(newValue);
|
|
updateValue(newValue);
|
|
debouncedSaveDraft(newValue);
|
|
setHasUserTyped(true);
|
|
|
|
// Check for @ mention and / action triggers
|
|
if (cursorPos !== undefined) {
|
|
const syntheticTarget = {
|
|
getBoundingClientRect: () => textAreaRef.current?.getBoundingClientRect?.() || new DOMRect(),
|
|
selectionStart: cursorPos,
|
|
selectionEnd: cursorPos,
|
|
value: newValue,
|
|
};
|
|
checkForMention(newValue, cursorPos, syntheticTarget as HTMLTextAreaElement);
|
|
}
|
|
}}
|
|
onCompositionStart={handleCompositionStart}
|
|
onCompositionEnd={handleCompositionEnd}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
ref={textAreaRef}
|
|
rows={1}
|
|
disabled={isUserInputDisabled}
|
|
style={{
|
|
maxHeight: `${maxHeight}px`,
|
|
overflowY: 'auto',
|
|
opacity: isRecording ? 0 : 1,
|
|
}}
|
|
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-20 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
|
|
/>
|
|
{isRecording && (
|
|
<div className="absolute inset-0 flex items-center pl-4 pr-20 pt-3 pb-1.5">
|
|
<WaveformVisualizer
|
|
audioContext={audioContext}
|
|
analyser={analyser}
|
|
isRecording={isRecording}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Inline action buttons on the right */}
|
|
<div className="flex items-center gap-1 px-2 relative">
|
|
{/* Microphone button - show only if dictation is enabled */}
|
|
{dictationSettings?.enabled && (
|
|
<>
|
|
{!canUseDictation ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
shape="round"
|
|
variant="outline"
|
|
onClick={() => {}}
|
|
disabled={true}
|
|
className="bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600 rounded-full px-6 py-2"
|
|
>
|
|
<Microphone />
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{dictationSettings.provider === 'openai' ? (
|
|
<p>
|
|
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
|
<b>Models.</b>
|
|
</p>
|
|
) : dictationSettings.provider === 'elevenlabs' ? (
|
|
<p>
|
|
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
|
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
|
</p>
|
|
) : dictationSettings.provider === null ? (
|
|
<p>
|
|
Dictation is not configured. Configure it in <b>Settings</b> {'>'}{' '}
|
|
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
|
</p>
|
|
) : (
|
|
<p>Dictation provider is not properly configured.</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
shape="round"
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
}}
|
|
disabled={isTranscribing}
|
|
className={`rounded-full px-6 py-2 ${
|
|
isRecording
|
|
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
|
|
: isTranscribing
|
|
? 'bg-slate-600 text-white cursor-not-allowed animate-pulse border-slate-600'
|
|
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600'
|
|
}`}
|
|
>
|
|
<Microphone />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Send/Stop button */}
|
|
{isLoading ? (
|
|
<Button
|
|
type="button"
|
|
onClick={onStop}
|
|
size="sm"
|
|
shape="round"
|
|
variant="outline"
|
|
className="bg-slate-600 text-white hover:bg-slate-700 border-slate-600 rounded-full px-6 py-2"
|
|
>
|
|
<Stop />
|
|
</Button>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
shape="round"
|
|
variant="outline"
|
|
disabled={isSubmitButtonDisabled}
|
|
className={`rounded-full px-10 py-2 flex items-center gap-2 ${
|
|
isSubmitButtonDisabled
|
|
? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600'
|
|
: 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer'
|
|
}`}
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
<span className="text-sm">Send</span>
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
{isExtensionsLoading
|
|
? 'Loading extensions...'
|
|
: isCompacting
|
|
? 'Compacting conversation...'
|
|
: isAnyImageLoading
|
|
? 'Waiting for images to save...'
|
|
: isAnyDroppedFileLoading
|
|
? 'Processing dropped files...'
|
|
: isRecording
|
|
? 'Recording...'
|
|
: isTranscribing
|
|
? 'Transcribing...'
|
|
: (chatContext?.agentWaitingMessage ?? 'Send')}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* Recording/transcribing status indicator - positioned above the button row */}
|
|
{(isRecording || isTranscribing) && (
|
|
<div className="absolute right-0 -top-8 bg-background-default px-2 py-1 rounded text-xs whitespace-nowrap shadow-md border border-borderSubtle">
|
|
{isTranscribing ? (
|
|
<span className="text-blue-500 flex items-center gap-1">
|
|
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
Transcribing...
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={`flex items-center gap-2 ${estimatedSize > 20 ? 'text-orange-500' : 'text-textSubtle'}`}
|
|
>
|
|
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
|
{Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB
|
|
{estimatedSize > 20 && <span className="text-xs">(near 25MB limit)</span>}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* Combined files and images preview */}
|
|
{(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
|
|
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
|
|
{/* Render pasted images first */}
|
|
{pastedImages.map((img) => (
|
|
<div key={img.id} className="relative group w-20 h-20">
|
|
{img.dataUrl && (
|
|
<img
|
|
src={img.dataUrl}
|
|
alt={`Pasted image ${img.id}`}
|
|
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
|
|
/>
|
|
)}
|
|
{img.isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
|
|
</div>
|
|
)}
|
|
{img.error && !img.isLoading && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
|
|
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
|
|
{img.error.substring(0, 50)}
|
|
</p>
|
|
{img.dataUrl && (
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleRetryImageSave(img.id)}
|
|
title="Retry saving image"
|
|
variant="outline"
|
|
size="xs"
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!img.isLoading && (
|
|
<Button
|
|
type="button"
|
|
shape="round"
|
|
onClick={() => handleRemovePastedImage(img.id)}
|
|
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
|
|
aria-label="Remove image"
|
|
variant="outline"
|
|
size="xs"
|
|
>
|
|
<Close />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{/* Render dropped files after pasted images */}
|
|
{allDroppedFiles.map((file) => (
|
|
<div key={file.id} className="relative group">
|
|
{file.isImage ? (
|
|
// Image preview
|
|
<div className="w-20 h-20">
|
|
{file.dataUrl && (
|
|
<img
|
|
src={file.dataUrl}
|
|
alt={file.name}
|
|
className={`w-full h-full object-cover rounded border ${file.error ? 'border-red-500' : 'border-borderStandard'}`}
|
|
/>
|
|
)}
|
|
{file.isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
|
|
</div>
|
|
)}
|
|
{file.error && !file.isLoading && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
|
|
<p className="text-red-400 text-[10px] leading-tight break-all">
|
|
{file.error.substring(0, 30)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// File box preview
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-bgSubtle border border-borderStandard rounded-lg min-w-[120px] max-w-[200px]">
|
|
<div className="flex-shrink-0 w-8 h-8 bg-background-default border border-borderSubtle rounded flex items-center justify-center text-xs font-mono text-textSubtle">
|
|
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-textStandard truncate" title={file.name}>
|
|
{file.name}
|
|
</p>
|
|
<p className="text-xs text-textSubtle">{file.type || 'Unknown type'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!file.isLoading && (
|
|
<Button
|
|
type="button"
|
|
shape="round"
|
|
onClick={() => handleRemoveDroppedFile(file.id)}
|
|
className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
|
|
aria-label="Remove file"
|
|
variant="outline"
|
|
size="xs"
|
|
>
|
|
<Close />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Secondary actions and controls row below input */}
|
|
<div className="flex flex-row items-center gap-1 p-2 relative">
|
|
{/* Directory path */}
|
|
<DirSwitcher />
|
|
<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 text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors !px-0"
|
|
>
|
|
<Action className="w-4 h-4 min-[1050px]:mr-1" />
|
|
<span className="text-xs hidden min-[1050px]:inline">Actions</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Quick Actions</TooltipContent>
|
|
</Tooltip>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
|
|
{/* Attach button */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
onClick={handleFileSelect}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex items-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors !px-0"
|
|
>
|
|
<Attach className="w-4 h-4 min-[1050px]:mr-1" />
|
|
<span className="text-xs hidden min-[1050px]:inline">Attach</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Attach file or directory</TooltipContent>
|
|
</Tooltip>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
|
|
{/* Model selector, mode selector, alerts, summarize button */}
|
|
<div className="flex flex-row items-center">
|
|
{/* Cost Tracker */}
|
|
{COST_TRACKING_ENABLED && (
|
|
<>
|
|
<CostTracker
|
|
inputTokens={inputTokens}
|
|
outputTokens={outputTokens}
|
|
sessionCosts={sessionCosts}
|
|
/>
|
|
</>
|
|
)}
|
|
<Tooltip>
|
|
<div>
|
|
<ModelsBottomBar
|
|
sessionId={sessionId}
|
|
dropdownRef={dropdownRef}
|
|
setView={setView}
|
|
alerts={alerts}
|
|
recipeConfig={recipeConfig}
|
|
hasMessages={messages.length > 0}
|
|
/>
|
|
</div>
|
|
</Tooltip>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
<BottomMenuModeSelection />
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={() => setIsGoosehintsModalOpen?.(true)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex items-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors px-0"
|
|
>
|
|
<FolderKey size={16} className="min-[1050px]:mr-1" />
|
|
<span className="text-xs hidden min-[1050px]:inline">Hints</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Configure goosehints</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<MentionPopover
|
|
ref={mentionPopoverRef}
|
|
isOpen={mentionPopover.isOpen}
|
|
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
|
|
onSelect={handleMentionFileSelect}
|
|
position={mentionPopover.position}
|
|
query={mentionPopover.query}
|
|
selectedIndex={mentionPopover.selectedIndex}
|
|
onSelectedIndexChange={(index) =>
|
|
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>
|
|
);
|
|
}
|