1622 lines
57 KiB
TypeScript
1622 lines
57 KiB
TypeScript
import { AppEvents } from '../constants/events';
|
|
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { Bug, ChefHat, 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 } from './icons';
|
|
import { ChatState } from '../types/chatState';
|
|
import debounce from 'lodash/debounce';
|
|
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
|
import { DirSwitcher } from './bottom_menu/DirSwitcher';
|
|
import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
|
|
import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection';
|
|
import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection';
|
|
import { AlertType, useAlerts } from './alerts';
|
|
import { useConfig } from './ConfigContext';
|
|
import { useModelAndProvider } from './ModelAndProviderContext';
|
|
import { useAudioRecorder } from '../hooks/useAudioRecorder';
|
|
import { toastError } from '../toasts';
|
|
import MentionPopover, { DisplayItemWithMatch } from './MentionPopover';
|
|
import { COST_TRACKING_ENABLED } from '../updates';
|
|
import { CostTracker } from './bottom_menu/CostTracker';
|
|
import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
|
import { Recipe } from '../recipe';
|
|
import { MessageQueue, QueuedMessage } from './MessageQueue';
|
|
import { detectInterruption } from '../utils/interruptionDetector';
|
|
import { DiagnosticsModal } from './ui/Diagnostics';
|
|
import { getSession, Message } from '../api';
|
|
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
|
|
import CreateEditRecipeModal from './recipes/CreateEditRecipeModal';
|
|
import { getInitialWorkingDir } from '../utils/workingDir';
|
|
import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils';
|
|
import {
|
|
trackFileAttached,
|
|
trackVoiceDictation,
|
|
trackDiagnosticsOpened,
|
|
trackCreateRecipeOpened,
|
|
trackEditRecipeOpened,
|
|
} from '../utils/analytics';
|
|
import { getNavigationShortcutText } from '../utils/keyboardShortcuts';
|
|
import { UserInput, ImageData } from '../types/message';
|
|
import { compressImageDataUrl } from '../utils/conversionUtils';
|
|
import { fetchCanonicalModelInfo } from '../utils/canonical';
|
|
|
|
interface PastedImage {
|
|
id: string;
|
|
dataUrl: string;
|
|
isLoading: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
const MAX_IMAGES_PER_MESSAGE = 10;
|
|
|
|
// 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
|
|
|
|
// Manual compact trigger message - must match backend constant
|
|
const MANUAL_COMPACT_TRIGGER = '/compact';
|
|
|
|
interface ChatInputProps {
|
|
sessionId: string | null;
|
|
handleSubmit: (input: UserInput) => void;
|
|
chatState: ChatState;
|
|
setChatState?: (state: ChatState) => void;
|
|
onStop?: () => void;
|
|
commandHistory?: string[];
|
|
initialValue?: string;
|
|
droppedFiles?: DroppedFile[];
|
|
onFilesProcessed?: () => void;
|
|
setView: (view: View) => void;
|
|
totalTokens?: number;
|
|
accumulatedInputTokens?: number;
|
|
accumulatedOutputTokens?: number;
|
|
messages?: Message[];
|
|
sessionCosts?: {
|
|
[key: string]: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalCost: number;
|
|
};
|
|
};
|
|
disableAnimation?: boolean;
|
|
recipe?: Recipe | null;
|
|
recipeId?: string | null;
|
|
recipeAccepted?: boolean;
|
|
initialPrompt?: string;
|
|
toolCount: number;
|
|
append?: (message: Message) => void;
|
|
onWorkingDirChange?: (newDir: string) => void;
|
|
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
|
|
}
|
|
|
|
export default function ChatInput({
|
|
sessionId,
|
|
handleSubmit,
|
|
chatState = ChatState.Idle,
|
|
setChatState,
|
|
onStop,
|
|
commandHistory = [],
|
|
initialValue = '',
|
|
droppedFiles = [],
|
|
onFilesProcessed,
|
|
setView,
|
|
totalTokens,
|
|
accumulatedInputTokens,
|
|
accumulatedOutputTokens,
|
|
messages = [],
|
|
disableAnimation = false,
|
|
sessionCosts,
|
|
recipe,
|
|
recipeId,
|
|
recipeAccepted,
|
|
initialPrompt,
|
|
toolCount,
|
|
append: _append,
|
|
onWorkingDirChange,
|
|
inputRef,
|
|
}: 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[]>([]);
|
|
const [isFilePickerOpen, setIsFilePickerOpen] = useState(false);
|
|
|
|
// 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 { getProviders } = useConfig();
|
|
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
|
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
|
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
|
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false);
|
|
const [showEditRecipeModal, setShowEditRecipeModal] = useState(false);
|
|
const [sessionWorkingDir, setSessionWorkingDir] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
const fetchSessionWorkingDir = async () => {
|
|
try {
|
|
const response = await getSession({ path: { session_id: sessionId } });
|
|
if (response.data?.working_dir) {
|
|
setSessionWorkingDir(response.data.working_dir);
|
|
}
|
|
} catch (error) {
|
|
console.error('[ChatInput] Failed to fetch session working dir:', error);
|
|
}
|
|
};
|
|
|
|
fetchSessionWorkingDir();
|
|
}, [sessionId]);
|
|
|
|
// 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({ msg: nextMessage.content, images: nextMessage.images });
|
|
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;
|
|
isSlashCommand: boolean;
|
|
}>({
|
|
isOpen: false,
|
|
position: { x: 0, y: 0 },
|
|
query: '',
|
|
mentionStart: -1,
|
|
selectedIndex: 0,
|
|
isSlashCommand: false,
|
|
});
|
|
const mentionPopoverRef = useRef<{
|
|
getDisplayFiles: () => DisplayItemWithMatch[];
|
|
selectFile: (index: number) => void;
|
|
}>(null);
|
|
|
|
// Audio recorder hook for voice dictation
|
|
const {
|
|
isEnabled,
|
|
dictationProvider,
|
|
isRecording,
|
|
isTranscribing,
|
|
startRecording,
|
|
stopRecording,
|
|
} = useAudioRecorder({
|
|
onTranscription: (text) => {
|
|
trackVoiceDictation('transcribed');
|
|
|
|
let filteredText = text.replace(/\([^)]*\)/g, '').trim();
|
|
|
|
if (!filteredText) {
|
|
return;
|
|
}
|
|
|
|
const shouldAutoSubmit = /\bsubmit[.,!?;'"\s]*$/i.test(filteredText);
|
|
|
|
const cleanedText = shouldAutoSubmit
|
|
? filteredText.replace(/\bsubmit[.,!?;'"\s]*$/i, '').trim()
|
|
: filteredText;
|
|
|
|
const newValue =
|
|
displayValue.trim() && cleanedText
|
|
? `${displayValue.trim()} ${cleanedText}`
|
|
: displayValue.trim() || cleanedText;
|
|
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
|
|
if (shouldAutoSubmit && newValue.trim()) {
|
|
trackVoiceDictation('auto_submit');
|
|
setTimeout(() => {
|
|
performSubmit(newValue);
|
|
}, 100);
|
|
} else {
|
|
textAreaRef.current?.focus();
|
|
}
|
|
},
|
|
onError: (message) => {
|
|
const errorType = 'DictationError';
|
|
trackVoiceDictation('error', undefined, errorType);
|
|
toastError({
|
|
title: 'Dictation Error',
|
|
msg: message,
|
|
});
|
|
},
|
|
});
|
|
const internalTextAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
const textAreaRef = inputRef || internalTextAreaRef;
|
|
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
|
|
|
useEffect(() => {
|
|
setValue(initialValue);
|
|
setDisplayValue(initialValue);
|
|
setPastedImages([]);
|
|
setHistoryIndex(-1);
|
|
setIsInGlobalHistory(false);
|
|
setHasUserTyped(false);
|
|
}, [initialValue]);
|
|
|
|
// 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, textAreaRef]);
|
|
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [savedInput, setSavedInput] = useState('');
|
|
const [isInGlobalHistory, setIsInGlobalHistory] = useState(false);
|
|
const [hasUserTyped, setHasUserTyped] = useState(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) => {
|
|
setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, [textAreaRef]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Priority 1: Check predefined models from environment
|
|
const predefinedModels = getPredefinedModelsFromEnv();
|
|
const predefinedModel = predefinedModels.find((m) => m.name === model);
|
|
if (predefinedModel?.context_limit) {
|
|
setTokenLimit(predefinedModel.context_limit);
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// Priority 2: Check canonical model info (source of truth)
|
|
const canonicalInfo = await fetchCanonicalModelInfo(provider, model);
|
|
if (canonicalInfo?.context_limit) {
|
|
setTokenLimit(canonicalInfo.context_limit);
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// Priority 3: Fall back to provider metadata known_models (may be outdated)
|
|
const providers = await getProviders(true);
|
|
const currentProvider = providers.find((p) => p.name === provider);
|
|
if (currentProvider?.metadata?.known_models) {
|
|
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
|
|
if (modelConfig?.context_limit) {
|
|
setTokenLimit(modelConfig.context_limit);
|
|
setIsTokenLimitLoaded(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Priority 4: Use default if nothing else found
|
|
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]);
|
|
|
|
// Handle tool count alerts and token usage
|
|
useEffect(() => {
|
|
clearAlerts();
|
|
|
|
// Show alert when either there is registered token usage, or we know the limit
|
|
if ((totalTokens && totalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) {
|
|
addAlert({
|
|
type: AlertType.Info,
|
|
message: 'Context window',
|
|
progress: {
|
|
current: totalTokens || 0,
|
|
total: tokenLimit,
|
|
},
|
|
showCompactButton: true,
|
|
compactButtonDisabled: !totalTokens,
|
|
onCompact: () => {
|
|
window.dispatchEvent(new CustomEvent(AppEvents.HIDE_ALERT_POPOVER));
|
|
handleSubmit({ msg: MANUAL_COMPACT_TRIGGER, images: [] });
|
|
},
|
|
compactIcon: <ScrollText size={12} />,
|
|
});
|
|
}
|
|
|
|
// 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
|
|
}, [totalTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]);
|
|
|
|
// Cleanup effect for component unmount - prevent memory leaks
|
|
useEffect(() => {
|
|
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 minTextareaHeight = 38;
|
|
|
|
const debouncedAutosize = useMemo(
|
|
() =>
|
|
debounce((element: HTMLTextAreaElement) => {
|
|
// Store current scroll position to prevent jump
|
|
const scrollTop = element.scrollTop;
|
|
|
|
// Temporarily set to auto to measure natural height, but use minHeight to prevent collapse
|
|
element.style.height = `${minTextareaHeight}px`;
|
|
const scrollHeight = element.scrollHeight;
|
|
const newHeight = Math.max(minTextareaHeight, Math.min(scrollHeight, maxHeight));
|
|
element.style.height = `${newHeight}px`;
|
|
|
|
// Restore scroll position
|
|
element.scrollTop = scrollTop;
|
|
}, 50),
|
|
[maxHeight, minTextareaHeight]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (textAreaRef.current) {
|
|
debouncedAutosize(textAreaRef.current);
|
|
}
|
|
}, [debouncedAutosize, displayValue, textAreaRef]);
|
|
|
|
// Set consistent minimum height when displayValue is empty
|
|
useEffect(() => {
|
|
if (textAreaRef.current && displayValue === '') {
|
|
textAreaRef.current.style.height = `${minTextareaHeight}px`;
|
|
}
|
|
}, [displayValue, textAreaRef, minTextareaHeight]);
|
|
|
|
const handleChange = (evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const val = evt.target.value;
|
|
const cursorPosition = evt.target.selectionStart;
|
|
|
|
setDisplayValue(val);
|
|
updateValue(val);
|
|
setHasUserTyped(true);
|
|
checkForMentionOrSlash(val, cursorPosition, evt.target);
|
|
};
|
|
|
|
const checkForMentionOrSlash = (
|
|
text: string,
|
|
cursorPosition: number,
|
|
textArea: HTMLTextAreaElement
|
|
) => {
|
|
const isSlashCommand = text.startsWith('/');
|
|
const beforeCursor = text.slice(0, cursorPosition);
|
|
const lastAtIndex = isSlashCommand ? 0 : beforeCursor.lastIndexOf('@');
|
|
|
|
if (lastAtIndex === -1) {
|
|
// No @ found, close mention popover
|
|
setMentionPopover((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')) {
|
|
setMentionPopover((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
|
|
isSlashCommand,
|
|
// filteredFiles will be populated by the MentionPopover component
|
|
}));
|
|
};
|
|
|
|
const convertImagesToImageData = useCallback((): ImageData[] => {
|
|
const pastedImageData: ImageData[] = pastedImages
|
|
.filter((img) => img.dataUrl && !img.error && !img.isLoading)
|
|
.map((img) => {
|
|
const matches = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (matches) {
|
|
return {
|
|
data: matches[2],
|
|
mimeType: matches[1],
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter((img): img is ImageData => img !== null);
|
|
|
|
const droppedImageData: ImageData[] = allDroppedFiles
|
|
.filter((file) => file.isImage && file.dataUrl && !file.error && !file.isLoading)
|
|
.map((file) => {
|
|
const matches = file.dataUrl!.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (matches) {
|
|
return {
|
|
data: matches[2],
|
|
mimeType: matches[1],
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter((img): img is ImageData => img !== null);
|
|
|
|
return [...pastedImageData, ...droppedImageData];
|
|
}, [pastedImages, allDroppedFiles]);
|
|
|
|
const appendDroppedFilePaths = useCallback(
|
|
(text: string): string => {
|
|
const droppedFilePaths = allDroppedFiles
|
|
.filter((file) => !file.isImage && !file.error && !file.isLoading)
|
|
.map((file) => file.path);
|
|
|
|
if (droppedFilePaths.length > 0) {
|
|
const pathsString = droppedFilePaths.join(' ');
|
|
return text ? `${text} ${pathsString}` : pathsString;
|
|
}
|
|
return text;
|
|
},
|
|
[allDroppedFiles]
|
|
);
|
|
|
|
const clearInputState = useCallback(() => {
|
|
setDisplayValue('');
|
|
setValue('');
|
|
setPastedImages([]);
|
|
if (onFilesProcessed && droppedFiles.length > 0) {
|
|
onFilesProcessed();
|
|
}
|
|
if (localDroppedFiles.length > 0) {
|
|
setLocalDroppedFiles([]);
|
|
}
|
|
}, [droppedFiles.length, localDroppedFiles.length, onFilesProcessed, setLocalDroppedFiles]);
|
|
|
|
const handlePaste = async (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
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) {
|
|
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) {
|
|
const compressedDataUrl = await compressImageDataUrl(dataUrl);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === imageId ? { ...img, dataUrl: compressedDataUrl, 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?.();
|
|
};
|
|
}, [debouncedAutosize]);
|
|
|
|
// Handlers for composition events, which are crucial for proper IME behavior
|
|
const handleCompositionStart = () => {
|
|
setIsComposing(true);
|
|
};
|
|
|
|
const handleCompositionEnd = () => {
|
|
setIsComposing(false);
|
|
};
|
|
|
|
const handleHistoryNavigation = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleInterruptionAndQueue = () => {
|
|
if (!isLoading || !hasSubmittableContent) {
|
|
return false;
|
|
}
|
|
|
|
const imageData = convertImagesToImageData();
|
|
const contentToQueue = appendDroppedFilePaths(displayValue.trim());
|
|
|
|
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: QueuedMessage = {
|
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
|
content: contentToQueue,
|
|
timestamp: Date.now(),
|
|
images: imageData,
|
|
};
|
|
|
|
// Add the interruption message to the front of the queue so it gets sent first
|
|
setQueuedMessages((prev) => [interruptionMessage, ...prev]);
|
|
|
|
clearInputState();
|
|
return true;
|
|
}
|
|
|
|
const newMessage: QueuedMessage = {
|
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
|
content: contentToQueue,
|
|
timestamp: Date.now(),
|
|
images: imageData,
|
|
};
|
|
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;
|
|
});
|
|
clearInputState();
|
|
return true;
|
|
};
|
|
|
|
const canSubmit =
|
|
!isLoading &&
|
|
(displayValue.trim() ||
|
|
pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) ||
|
|
allDroppedFiles.some((file) => !file.error && !file.isLoading));
|
|
|
|
const performSubmit = useCallback(
|
|
(text?: string) => {
|
|
const imageData = convertImagesToImageData();
|
|
const textToSend = appendDroppedFilePaths(text ?? displayValue.trim());
|
|
|
|
if (textToSend || imageData.length > 0) {
|
|
// Store original message in history
|
|
if (displayValue.trim()) {
|
|
LocalMessageStorage.addMessage(displayValue);
|
|
} else {
|
|
const droppedFilePaths = allDroppedFiles
|
|
.filter((file) => !file.isImage && !file.error && !file.isLoading)
|
|
.map((file) => file.path);
|
|
if (droppedFilePaths.length > 0) {
|
|
LocalMessageStorage.addMessage(droppedFilePaths.join(' '));
|
|
}
|
|
}
|
|
|
|
handleSubmit({ msg: textToSend, images: imageData });
|
|
|
|
// 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);
|
|
}
|
|
|
|
clearInputState();
|
|
setHistoryIndex(-1);
|
|
setSavedInput('');
|
|
setIsInGlobalHistory(false);
|
|
setHasUserTyped(false);
|
|
}
|
|
},
|
|
[
|
|
convertImagesToImageData,
|
|
appendDroppedFilePaths,
|
|
displayValue,
|
|
allDroppedFiles,
|
|
handleSubmit,
|
|
lastInterruption,
|
|
clearInputState,
|
|
]
|
|
);
|
|
|
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (isLoading && hasSubmittableContent) {
|
|
handleInterruptionAndQueue();
|
|
return;
|
|
}
|
|
const canSubmit =
|
|
!isLoading &&
|
|
(displayValue.trim() ||
|
|
pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) ||
|
|
allDroppedFiles.some((file) => !file.error && !file.isLoading));
|
|
if (canSubmit) {
|
|
performSubmit();
|
|
}
|
|
};
|
|
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const handleFileSelect = () => {
|
|
if (isFilePickerOpen) return;
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
setIsFilePickerOpen(true);
|
|
const file = files[0];
|
|
const isImage = file.type.startsWith('image/');
|
|
|
|
if (isImage) {
|
|
trackFileAttached('file');
|
|
|
|
if (pastedImages.length >= MAX_IMAGES_PER_MESSAGE) {
|
|
console.warn(`Maximum ${MAX_IMAGES_PER_MESSAGE} images per message`);
|
|
setIsFilePickerOpen(false);
|
|
return;
|
|
}
|
|
|
|
const uniqueId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
setPastedImages((prev) => [
|
|
...prev,
|
|
{
|
|
id: uniqueId,
|
|
dataUrl: '',
|
|
isLoading: true,
|
|
error: undefined,
|
|
},
|
|
]);
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (evt) => {
|
|
const dataUrl = evt.target?.result as string;
|
|
if (dataUrl) {
|
|
const compressedDataUrl = await compressImageDataUrl(dataUrl);
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === uniqueId
|
|
? { ...img, dataUrl: compressedDataUrl, isLoading: false, error: undefined }
|
|
: img
|
|
)
|
|
);
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
setPastedImages((prev) =>
|
|
prev.map((img) =>
|
|
img.id === uniqueId
|
|
? { ...img, isLoading: false, error: 'Failed to read image file' }
|
|
: img
|
|
)
|
|
);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} else {
|
|
trackFileAttached('file');
|
|
const path = window.electron.getPathForFile(file);
|
|
const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path;
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
}
|
|
|
|
textAreaRef.current?.focus();
|
|
setIsFilePickerOpen(false);
|
|
if (e.target) {
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
const handleMentionItemSelect = (itemText: string) => {
|
|
// Replace the @ mention with the file path
|
|
const beforeMention = displayValue.slice(0, mentionPopover.mentionStart);
|
|
const afterMention = displayValue.slice(
|
|
mentionPopover.mentionStart + 1 + mentionPopover.query.length
|
|
);
|
|
const newValue = `${beforeMention}${itemText}${afterMention}`;
|
|
|
|
setDisplayValue(newValue);
|
|
setValue(newValue);
|
|
setMentionPopover((prev) => ({ ...prev, isOpen: false }));
|
|
textAreaRef.current?.focus();
|
|
|
|
// Set cursor position after the inserted file path
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
const newCursorPosition = beforeMention.length + itemText.length;
|
|
textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const hasSubmittableContent =
|
|
displayValue.trim() ||
|
|
pastedImages.some((img) => img.dataUrl && !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 ||
|
|
chatState === ChatState.RestartingAgent;
|
|
|
|
const getSubmitButtonTooltip = (): string => {
|
|
if (isAnyImageLoading) return 'Waiting for images to save...';
|
|
if (isAnyDroppedFileLoading) return 'Processing dropped files...';
|
|
if (isRecording) return 'Recording...';
|
|
if (isTranscribing) return 'Transcribing...';
|
|
if (chatState === ChatState.RestartingAgent) return 'Restarting session...';
|
|
if (!hasSubmittableContent) return 'Type a message to send';
|
|
return 'Send';
|
|
};
|
|
|
|
// 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({ msg: messageToSend.content, images: messageToSend.images });
|
|
|
|
// 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({ msg: nextMessage.content, images: nextMessage.images });
|
|
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-border-strong hover:border-border-strong'
|
|
: 'border-border-default hover:border-border-default'
|
|
} bg-background-default z-10 rounded-t-2xl`}
|
|
data-drop-zone="true"
|
|
onDrop={handleLocalDrop}
|
|
onDragOver={handleLocalDragOver}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
onChange={handleFileInputChange}
|
|
style={{ display: 'none' }}
|
|
accept="*/*"
|
|
/>
|
|
{/* 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-border-default"
|
|
/>
|
|
)}
|
|
{/* Input row with inline action buttons wrapped in form */}
|
|
<form onSubmit={onFormSubmit} className="relative">
|
|
<div className="relative">
|
|
<textarea
|
|
data-testid="chat-input"
|
|
autoFocus
|
|
id="dynamic-textarea"
|
|
placeholder={isRecording ? '' : getNavigationShortcutText()}
|
|
value={displayValue}
|
|
onChange={handleChange}
|
|
onCompositionStart={handleCompositionStart}
|
|
onCompositionEnd={handleCompositionEnd}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
ref={textAreaRef}
|
|
rows={1}
|
|
readOnly={isRecording}
|
|
style={{
|
|
minHeight: `${minTextareaHeight}px`,
|
|
maxHeight: `${maxHeight}px`,
|
|
overflowY: 'auto',
|
|
paddingRight: dictationProvider ? '180px' : '120px',
|
|
}}
|
|
className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 text-sm resize-none text-text-default placeholder:text-text-muted"
|
|
/>
|
|
|
|
{/* Inline action buttons - absolutely positioned on the right */}
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
{/* Microphone button - show only if provider is selected */}
|
|
{dictationProvider && (
|
|
<>
|
|
{!isEnabled ? (
|
|
<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>
|
|
{dictationProvider === 'openai' ? (
|
|
<p>
|
|
OpenAI API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
|
<b>Models.</b>
|
|
</p>
|
|
) : dictationProvider === 'elevenlabs' ? (
|
|
<p>
|
|
ElevenLabs API key is not configured. Set it up in <b>Settings</b> {'>'}{' '}
|
|
<b>Chat</b> {'>'} <b>Voice Dictation.</b>
|
|
</p>
|
|
) : dictationProvider === 'local' ? (
|
|
<p>
|
|
Local Whisper model not found. Download a model in{' '}
|
|
<b>Settings > Dictation > Local (Offline)</b>
|
|
</p>
|
|
) : (
|
|
<p>Dictation provider is not properly configured.</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
shape="round"
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (isRecording) {
|
|
trackVoiceDictation('stop');
|
|
stopRecording();
|
|
} else {
|
|
trackVoiceDictation('start');
|
|
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>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Voice dictation
|
|
{isRecording ? '' : ' • Say "submit" to send'}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Send/Stop button */}
|
|
{isLoading && !hasSubmittableContent ? (
|
|
<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>{getSubmitButtonTooltip()}</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-border-default">
|
|
<span className="flex items-center gap-2">
|
|
{isRecording && (
|
|
<span className="flex items-center gap-1 text-text-muted">
|
|
<span className="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
|
Listening
|
|
</span>
|
|
)}
|
|
{isRecording && isTranscribing && <span className="text-text-muted">•</span>}
|
|
{isTranscribing && (
|
|
<span className="flex items-center gap-1 text-blue-500">
|
|
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
|
Transcribing
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Combined files and images preview */}
|
|
{(pastedImages.length > 0 || allDroppedFiles.length > 0) && (
|
|
<div className="flex flex-wrap gap-2 p-4 mt-2 border-t border-border-default">
|
|
{/* 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-border-default'}`}
|
|
/>
|
|
)}
|
|
{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">
|
|
{img.error.substring(0, 50)}
|
|
</p>
|
|
</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-border-default'}`}
|
|
/>
|
|
)}
|
|
{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-border-default rounded-lg min-w-[120px] max-w-[200px]">
|
|
<div className="flex-shrink-0 w-8 h-8 bg-background-default border border-border-default rounded flex items-center justify-center text-xs font-mono text-text-muted">
|
|
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm text-text-default truncate" title={file.name}>
|
|
{file.name}
|
|
</p>
|
|
<p className="text-xs text-text-muted">{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">
|
|
<DirSwitcher
|
|
className="mr-0"
|
|
sessionId={sessionId ?? undefined}
|
|
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
|
|
onWorkingDirChange={(newDir) => {
|
|
setSessionWorkingDir(newDir);
|
|
if (onWorkingDirChange) {
|
|
onWorkingDirChange(newDir);
|
|
}
|
|
}}
|
|
onRestartStart={() => setChatState?.(ChatState.RestartingAgent)}
|
|
onRestartEnd={() => setChatState?.(ChatState.Idle)}
|
|
/>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
onClick={handleFileSelect}
|
|
disabled={isFilePickerOpen}
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`flex items-center justify-center text-text-default/70 hover:text-text-default text-xs transition-colors ${isFilePickerOpen ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
>
|
|
<Attach className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Attach file</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 && (
|
|
<>
|
|
<div className="flex items-center h-full ml-1 mr-1">
|
|
<CostTracker
|
|
inputTokens={accumulatedInputTokens}
|
|
outputTokens={accumulatedOutputTokens}
|
|
sessionCosts={sessionCosts}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
<Tooltip>
|
|
<div>
|
|
<ModelsBottomBar
|
|
sessionId={sessionId}
|
|
dropdownRef={dropdownRef}
|
|
setView={setView}
|
|
alerts={alerts}
|
|
/>
|
|
</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" />
|
|
<BottomMenuExtensionSelection sessionId={sessionId} />
|
|
{sessionId && messages.length > 0 && (
|
|
<>
|
|
<div className="w-px h-4 bg-border-default mx-2" />
|
|
<div className="flex items-center h-full">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={() => {
|
|
if (recipe) {
|
|
trackEditRecipeOpened();
|
|
setShowEditRecipeModal(true);
|
|
} else {
|
|
trackCreateRecipeOpened();
|
|
setShowCreateRecipeModal(true);
|
|
}
|
|
}}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer"
|
|
>
|
|
<ChefHat size={16} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{recipe ? 'View/Edit Recipe' : 'Create Recipe from Session'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</>
|
|
)}
|
|
{sessionId && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
trackDiagnosticsOpened();
|
|
setDiagnosticsOpen(true);
|
|
}}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
|
|
>
|
|
<Bug className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Generate diagnostics bundle</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{sessionId && diagnosticsOpen && (
|
|
<DiagnosticsModal
|
|
isOpen={diagnosticsOpen}
|
|
onClose={() => setDiagnosticsOpen(false)}
|
|
sessionId={sessionId}
|
|
/>
|
|
)}
|
|
<MentionPopover
|
|
ref={mentionPopoverRef}
|
|
isOpen={mentionPopover.isOpen}
|
|
isSlashCommand={mentionPopover.isSlashCommand}
|
|
onClose={() => setMentionPopover((prev) => ({ ...prev, isOpen: false }))}
|
|
onSelect={handleMentionItemSelect}
|
|
position={mentionPopover.position}
|
|
query={mentionPopover.query}
|
|
selectedIndex={mentionPopover.selectedIndex}
|
|
onSelectedIndexChange={(index) =>
|
|
setMentionPopover((prev) => ({ ...prev, selectedIndex: index }))
|
|
}
|
|
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
|
|
/>
|
|
|
|
{sessionId && showCreateRecipeModal && (
|
|
<CreateRecipeFromSessionModal
|
|
isOpen={showCreateRecipeModal}
|
|
onClose={() => setShowCreateRecipeModal(false)}
|
|
sessionId={sessionId}
|
|
/>
|
|
)}
|
|
|
|
{recipe && showEditRecipeModal && (
|
|
<CreateEditRecipeModal
|
|
isOpen={showEditRecipeModal}
|
|
onClose={() => setShowEditRecipeModal(false)}
|
|
recipe={recipe}
|
|
recipeId={recipeId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|