* feat: re-organizaed input footer UI in chat Add StopIcon component and use it for aborting generation in chat controls Introduce ProviderLogoOrFallback to show provider logo or fallback icon in model controls Remove mobile-only abort button in StatusRow and simplify abort handling * feat: improved render of changelog in UpdateDialog
1480 lines
58 KiB
TypeScript
1480 lines
58 KiB
TypeScript
import React from 'react';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
RiAddCircleLine,
|
|
RiAiAgentLine,
|
|
RiAttachment2,
|
|
RiFileUploadLine,
|
|
RiSendPlane2Line,
|
|
} from '@remixicon/react';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
|
import type { AttachedFile, EditPermissionMode } from '@/stores/types/sessionTypes';
|
|
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
|
import { AttachedFilesList } from './FileAttachment';
|
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
|
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
|
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
|
import { cn } from '@/lib/utils';
|
|
import { ServerFilePicker } from './ServerFilePicker';
|
|
import { ModelControls } from './ModelControls';
|
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
|
import { StatusRow } from './StatusRow';
|
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|
import { toast } from '@/components/ui';
|
|
import { useFileStore } from '@/stores/fileStore';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isIMECompositionEvent } from '@/lib/ime';
|
|
import { StopIcon } from '@/components/icons/StopIcon';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { useContextStore } from '@/stores/contextStore';
|
|
|
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
|
|
|
interface ChatInputProps {
|
|
onOpenSettings?: () => void;
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
|
}
|
|
|
|
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
|
|
|
|
type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
type PermissionRule = { permission: string; pattern: string; action: PermissionAction };
|
|
|
|
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
|
|
if (!Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
const rules: PermissionRule[] = [];
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
continue;
|
|
}
|
|
const candidate = entry as Partial<PermissionRule>;
|
|
if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') {
|
|
continue;
|
|
}
|
|
if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') {
|
|
continue;
|
|
}
|
|
rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action });
|
|
}
|
|
return rules;
|
|
};
|
|
|
|
const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => {
|
|
const rules = asPermissionRuleset(ruleset);
|
|
if (!rules || rules.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
|
const rule = rules[i];
|
|
if (rule.permission === permission && rule.pattern === '*') {
|
|
return rule.action;
|
|
}
|
|
}
|
|
|
|
for (let i = rules.length - 1; i >= 0; i -= 1) {
|
|
const rule = rules[i];
|
|
if (rule.permission === '*' && rule.pattern === '*') {
|
|
return rule.action;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
|
const [message, setMessage] = React.useState('');
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
const [showFileMention, setShowFileMention] = React.useState(false);
|
|
const [mentionQuery, setMentionQuery] = React.useState('');
|
|
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
|
const [commandQuery, setCommandQuery] = React.useState('');
|
|
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
|
|
const [agentQuery, setAgentQuery] = React.useState('');
|
|
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
|
const [skillQuery, setSkillQuery] = React.useState('');
|
|
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
|
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
|
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
|
|
|
const sendMessage = useSessionStore((state) => state.sendMessage);
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
|
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
|
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
|
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
|
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
|
const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags);
|
|
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
|
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
|
const addServerFile = useSessionStore((state) => state.addServerFile);
|
|
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
|
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
|
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
|
const pendingInputText = useSessionStore((state) => state.pendingInputText);
|
|
|
|
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
|
const agents = getVisibleAgents();
|
|
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius } = useUIStore();
|
|
const { working } = useAssistantStatus();
|
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const prevWasAbortedRef = React.useRef(false);
|
|
const sendTriggeredByPointerDownRef = React.useRef(false);
|
|
|
|
// Message queue
|
|
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
|
const queuedMessages = useMessageQueueStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
if (!currentSessionId) return EMPTY_QUEUE;
|
|
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
|
|
},
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
|
|
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
|
|
|
|
// Session activity for auto-send on idle
|
|
const { phase: sessionPhase } = useCurrentSessionActivity();
|
|
const prevSessionPhaseRef = React.useRef(sessionPhase);
|
|
const autoSendTriggeredRef = React.useRef(false);
|
|
|
|
const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent<HTMLTextAreaElement>) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) {
|
|
return;
|
|
}
|
|
|
|
if (document.activeElement === textarea) {
|
|
return;
|
|
}
|
|
|
|
// Prevent iOS from scrolling the page to reveal the input.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const scroller = document.scrollingElement;
|
|
if (scroller && scroller.scrollTop !== 0) {
|
|
scroller.scrollTop = 0;
|
|
}
|
|
if (window.scrollY !== 0) {
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
try {
|
|
textarea.focus({ preventScroll: true });
|
|
} catch {
|
|
textarea.focus();
|
|
}
|
|
|
|
const len = textarea.value.length;
|
|
try {
|
|
textarea.setSelectionRange(len, len);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}, [isMobile]);
|
|
|
|
// Consume pending input text (e.g., from revert action)
|
|
React.useEffect(() => {
|
|
if (pendingInputText !== null) {
|
|
const text = consumePendingInputText();
|
|
if (text) {
|
|
setMessage(text);
|
|
// Focus textarea after setting message
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
}, [pendingInputText, consumePendingInputText]);
|
|
|
|
const currentAgent = React.useMemo(() => {
|
|
const selectedName = currentSessionId
|
|
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName)
|
|
: currentAgentName;
|
|
if (!selectedName) {
|
|
return undefined;
|
|
}
|
|
return agents.find((agent) => agent.name === selectedName);
|
|
}, [agents, currentAgentName, currentSessionId]);
|
|
|
|
const agentEditAction = React.useMemo<EditPermissionMode>(() => {
|
|
if (!currentAgent) {
|
|
return 'deny';
|
|
}
|
|
|
|
return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
|
|
}, [currentAgent]);
|
|
|
|
const sessionEditMode = useContextStore(
|
|
React.useCallback((state) => {
|
|
if (!currentAgentName) {
|
|
return undefined;
|
|
}
|
|
const sessionId = currentSessionId ?? '__global__';
|
|
return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask');
|
|
}, [currentAgentName, currentSessionId])
|
|
);
|
|
|
|
const selectionContextReady = Boolean(currentSessionId && currentAgentName);
|
|
|
|
const effectiveEditPermission = React.useMemo<EditPermissionMode>(() => {
|
|
// Only show accent when edits are effectively allowed.
|
|
if (agentEditAction === 'allow') {
|
|
return 'allow';
|
|
}
|
|
if (agentEditAction !== 'ask') {
|
|
return 'ask';
|
|
}
|
|
|
|
const sessionMode = selectionContextReady ? (sessionEditMode ?? 'ask') : 'ask';
|
|
return (sessionMode === 'allow' || sessionMode === 'full') ? 'allow' : 'ask';
|
|
}, [agentEditAction, selectionContextReady, sessionEditMode]);
|
|
|
|
const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]);
|
|
|
|
// VS Code webviews tend to have stronger status border colors; in web/desktop themes the same
|
|
// border tokens can already be subtle, so avoid double-softening there.
|
|
const softenBorderColor = React.useCallback((color: string) => (
|
|
isVSCodeRuntime()
|
|
? `color-mix(in srgb, ${color} 55%, transparent)`
|
|
: color
|
|
), []);
|
|
|
|
const chatInputWrapperStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
|
// Keep border width stable so toggling modes doesn't shift layout.
|
|
const baseBorderWidth = isVSCodeRuntime() ? 1 : 2;
|
|
|
|
const baseStyle: React.CSSProperties = {
|
|
borderRadius: cornerRadius,
|
|
};
|
|
|
|
if (!chatInputAccent) {
|
|
return { ...baseStyle, borderWidth: baseBorderWidth };
|
|
}
|
|
|
|
const borderColor = chatInputAccent.border ?? chatInputAccent.text;
|
|
return {
|
|
...baseStyle,
|
|
borderColor: softenBorderColor(borderColor),
|
|
borderWidth: baseBorderWidth,
|
|
};
|
|
}, [chatInputAccent, softenBorderColor, cornerRadius]);
|
|
|
|
const hasContent = message.trim() || attachedFiles.length > 0;
|
|
const hasQueuedMessages = queuedMessages.length > 0;
|
|
const canSend = hasContent || hasQueuedMessages;
|
|
|
|
const canAbort = working.isWorking;
|
|
|
|
// Add message to queue instead of sending
|
|
const handleQueueMessage = React.useCallback(() => {
|
|
if (!hasContent || !currentSessionId) return;
|
|
|
|
const messageToQueue = message.replace(/^\n+|\n+$/g, '');
|
|
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
|
|
|
|
addToQueue(currentSessionId, {
|
|
content: messageToQueue,
|
|
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
|
});
|
|
|
|
// Clear input and attachments
|
|
setMessage('');
|
|
if (attachmentsToQueue.length > 0) {
|
|
clearAttachedFiles();
|
|
}
|
|
|
|
if (!isMobile) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]);
|
|
|
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
|
|
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return;
|
|
|
|
// Re-pin and scroll to bottom when sending
|
|
scrollToBottom?.({ instant: true, force: true });
|
|
|
|
if (!currentProviderId || !currentModelId) {
|
|
console.warn('Cannot send message: provider or model not selected');
|
|
return;
|
|
}
|
|
|
|
// Build the primary message (first part) and additional parts
|
|
let primaryText = '';
|
|
let primaryAttachments: AttachedFile[] = [];
|
|
let agentMentionName: string | undefined;
|
|
const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = [];
|
|
|
|
// Process queued messages first
|
|
for (let i = 0; i < queuedMessages.length; i++) {
|
|
const queuedMsg = queuedMessages[i];
|
|
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
|
|
|
// Use agent mention from first message that has one
|
|
if (!agentMentionName && mention?.name) {
|
|
agentMentionName = mention.name;
|
|
}
|
|
|
|
if (i === 0) {
|
|
// First queued message becomes primary
|
|
primaryText = sanitizedText;
|
|
primaryAttachments = queuedMsg.attachments ?? [];
|
|
} else {
|
|
// Subsequent queued messages become additional parts
|
|
additionalParts.push({
|
|
text: sanitizedText,
|
|
attachments: queuedMsg.attachments,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add current input
|
|
if (hasContent) {
|
|
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
|
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
|
|
|
if (!agentMentionName && mention?.name) {
|
|
agentMentionName = mention.name;
|
|
}
|
|
|
|
if (queuedMessages.length === 0) {
|
|
// No queue - current input is primary
|
|
primaryText = sanitizedText;
|
|
primaryAttachments = attachmentsToSend;
|
|
} else {
|
|
// Has queue - current input is additional part
|
|
additionalParts.push({
|
|
text: sanitizedText,
|
|
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!primaryText && additionalParts.length === 0) return;
|
|
|
|
// Clear queue and input
|
|
if (currentSessionId && hasQueuedMessages) {
|
|
clearQueue(currentSessionId);
|
|
}
|
|
setMessage('');
|
|
if (attachedFiles.length > 0) {
|
|
clearAttachedFiles();
|
|
}
|
|
|
|
if (isMobile) {
|
|
textareaRef.current?.blur();
|
|
}
|
|
|
|
// Handle slash commands locally before sending
|
|
const normalizedCommand = primaryText.trimStart();
|
|
if (normalizedCommand.startsWith('/')) {
|
|
const commandName = normalizedCommand
|
|
.slice(1)
|
|
.trim()
|
|
.split(/\s+/)[0]
|
|
?.toLowerCase();
|
|
|
|
// NEW: /undo - revert to last message (populates input with reverted message text)
|
|
if (commandName === 'undo' && currentSessionId) {
|
|
await useSessionStore.getState().handleSlashUndo(currentSessionId);
|
|
// Don't clear message - pendingInputText will populate it with reverted message
|
|
scrollToBottom?.({ instant: true, force: true });
|
|
return; // Don't send to assistant
|
|
}
|
|
// NEW: /redo - unrevert or partial redo (populates input with message text)
|
|
else if (commandName === 'redo' && currentSessionId) {
|
|
await useSessionStore.getState().handleSlashRedo(currentSessionId);
|
|
// Don't clear message - pendingInputText will populate it
|
|
scrollToBottom?.({ instant: true, force: true });
|
|
return; // Don't send to assistant
|
|
}
|
|
// NEW: /timeline - open timeline dialog
|
|
else if (commandName === 'timeline' && currentSessionId) {
|
|
setTimelineDialogOpen(true);
|
|
setMessage('');
|
|
return; // Don't send to assistant
|
|
}
|
|
// /compact - call SDK summarize endpoint
|
|
else if (commandName === 'compact' && currentSessionId) {
|
|
try {
|
|
const { opencodeClient } = await import('@/lib/opencode/client');
|
|
const directory = opencodeClient.getDirectory();
|
|
const response = await opencodeClient.getApiClient().session.summarize({
|
|
sessionID: currentSessionId,
|
|
directory: directory || undefined,
|
|
providerID: currentProviderId,
|
|
modelID: currentModelId,
|
|
});
|
|
if (response.error) {
|
|
throw new Error('Failed to compact session');
|
|
}
|
|
scrollToBottom?.({ instant: true, force: true });
|
|
} catch (error) {
|
|
console.error('Failed to compact session:', error);
|
|
toast.error('Failed to compact session');
|
|
}
|
|
setMessage('');
|
|
return; // Don't send to assistant
|
|
}
|
|
}
|
|
|
|
// Collect all attachments for error recovery
|
|
const allAttachments = [
|
|
...primaryAttachments,
|
|
...additionalParts.flatMap(p => p.attachments ?? []),
|
|
];
|
|
|
|
void sendMessage(
|
|
primaryText,
|
|
currentProviderId,
|
|
currentModelId,
|
|
currentAgentName,
|
|
primaryAttachments,
|
|
agentMentionName,
|
|
additionalParts.length > 0 ? additionalParts : undefined,
|
|
currentVariant
|
|
).catch((error: unknown) => {
|
|
const rawMessage =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === 'string'
|
|
? error
|
|
: String(error ?? '');
|
|
const normalized = rawMessage.toLowerCase();
|
|
|
|
console.error('Message send failed:', rawMessage || error);
|
|
|
|
const isSoftNetworkError =
|
|
normalized.includes('timeout') ||
|
|
normalized.includes('timed out') ||
|
|
normalized.includes('may still be processing') ||
|
|
normalized.includes('being processed') ||
|
|
normalized.includes('failed to fetch') ||
|
|
normalized.includes('networkerror') ||
|
|
normalized.includes('network error') ||
|
|
normalized.includes('gateway timeout') ||
|
|
normalized === 'failed to send message';
|
|
|
|
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
|
|
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
|
|
if (allAttachments.length > 0) {
|
|
useFileStore.setState({ attachedFiles: allAttachments });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isSoftNetworkError) {
|
|
return;
|
|
}
|
|
|
|
if (allAttachments.length > 0) {
|
|
useFileStore.setState({ attachedFiles: allAttachments });
|
|
}
|
|
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
|
|
});
|
|
|
|
if (!isMobile) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
// Keep a ref to handleSubmit for auto-send effect
|
|
const handleSubmitRef = React.useRef(handleSubmit);
|
|
handleSubmitRef.current = handleSubmit;
|
|
|
|
// Auto-send queued messages when session becomes idle (but not after abort)
|
|
React.useEffect(() => {
|
|
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
|
|
const isNowIdle = sessionPhase === 'idle';
|
|
|
|
// Check if session was recently aborted (within last 2 seconds)
|
|
const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => {
|
|
const abortRecord = sessionAbortFlags.get(currentSessionId);
|
|
if (!abortRecord) return false;
|
|
const timeSinceAbort = Date.now() - abortRecord.timestamp;
|
|
return timeSinceAbort < 2000;
|
|
})();
|
|
|
|
// Detect transition from working to idle, but skip if aborted
|
|
if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) {
|
|
// Prevent double-triggering
|
|
autoSendTriggeredRef.current = true;
|
|
|
|
// Use setTimeout to avoid calling during render
|
|
setTimeout(() => {
|
|
if (currentSessionId && currentProviderId && currentModelId) {
|
|
void handleSubmitRef.current();
|
|
}
|
|
autoSendTriggeredRef.current = false;
|
|
}, 100);
|
|
}
|
|
|
|
prevSessionPhaseRef.current = sessionPhase;
|
|
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Early return during IME composition to prevent interference with autocomplete.
|
|
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
|
if (isIMECompositionEvent(e)) return;
|
|
|
|
if (showCommandAutocomplete && commandRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
commandRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showAgentAutocomplete && agentRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
agentRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showSkillAutocomplete && skillRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
skillRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (showFileMention && mentionRef.current) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
mentionRef.current.handleKeyDown(e.key);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) {
|
|
e.preventDefault();
|
|
cycleAgent();
|
|
return;
|
|
}
|
|
|
|
// Handle Enter/Ctrl+Enter based on queue mode
|
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
|
e.preventDefault();
|
|
|
|
const isCtrlEnter = e.ctrlKey || e.metaKey;
|
|
|
|
// Queue mode: Enter queues, Ctrl+Enter sends
|
|
// Normal mode: Enter sends, Ctrl+Enter queues
|
|
// Note: Queueing only works when there's an existing session (currentSessionId)
|
|
// For new sessions (draft), always send immediately
|
|
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
|
|
|
if (queueModeEnabled) {
|
|
if (isCtrlEnter || !canQueue) {
|
|
// Ctrl+Enter sends, or Enter when can't queue (new session)
|
|
handleSubmit();
|
|
} else {
|
|
// Enter queues when we have a session
|
|
handleQueueMessage();
|
|
}
|
|
} else {
|
|
if (isCtrlEnter && canQueue) {
|
|
// Ctrl+Enter queues when we have a session
|
|
handleQueueMessage();
|
|
} else {
|
|
// Enter sends
|
|
handleSubmit();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const startAbortIndicator = React.useCallback(() => {
|
|
if (abortTimeoutRef.current) {
|
|
clearTimeout(abortTimeoutRef.current);
|
|
abortTimeoutRef.current = null;
|
|
}
|
|
|
|
setShowAbortStatus(true);
|
|
|
|
abortTimeoutRef.current = setTimeout(() => {
|
|
setShowAbortStatus(false);
|
|
abortTimeoutRef.current = null;
|
|
}, 1800);
|
|
}, []);
|
|
|
|
const handleAbort = React.useCallback(() => {
|
|
clearAbortPrompt();
|
|
startAbortIndicator();
|
|
|
|
void abortCurrentOperation();
|
|
}, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]);
|
|
|
|
const cycleAgent = () => {
|
|
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
|
|
|
|
if (primaryAgents.length <= 1) return;
|
|
|
|
const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName);
|
|
const nextIndex = (currentIndex + 1) % primaryAgents.length;
|
|
const nextAgent = primaryAgents[nextIndex];
|
|
|
|
setAgent(nextAgent.name);
|
|
|
|
if (currentSessionId) {
|
|
|
|
saveSessionAgentSelection(currentSessionId, nextAgent.name);
|
|
}
|
|
};
|
|
|
|
const adjustTextareaHeight = React.useCallback(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) {
|
|
return;
|
|
}
|
|
|
|
textarea.style.height = 'auto';
|
|
|
|
const view = textarea.ownerDocument?.defaultView;
|
|
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
|
const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN;
|
|
const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN;
|
|
const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN;
|
|
const fallbackLineHeight = 22;
|
|
const fallbackPadding = 16;
|
|
const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom)
|
|
? fallbackPadding
|
|
: paddingTop + paddingBottom;
|
|
const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight;
|
|
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal;
|
|
const scrollHeight = textarea.scrollHeight || textarea.offsetHeight;
|
|
const nextHeight = Math.min(scrollHeight, maxHeight);
|
|
|
|
textarea.style.height = `${nextHeight}px`;
|
|
textarea.style.maxHeight = `${maxHeight}px`;
|
|
|
|
setTextareaSize((prev) => {
|
|
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
|
return prev;
|
|
}
|
|
return { height: nextHeight, maxHeight };
|
|
});
|
|
}, []);
|
|
|
|
React.useLayoutEffect(() => {
|
|
adjustTextareaHeight();
|
|
}, [adjustTextareaHeight, message, isMobile]);
|
|
|
|
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
|
if (value.startsWith('/')) {
|
|
const firstSpace = value.indexOf(' ');
|
|
const firstNewline = value.indexOf('\n');
|
|
const commandEnd = Math.min(
|
|
firstSpace === -1 ? value.length : firstSpace,
|
|
firstNewline === -1 ? value.length : firstNewline
|
|
);
|
|
|
|
if (cursorPosition <= commandEnd && firstSpace === -1) {
|
|
const commandText = value.substring(1, commandEnd);
|
|
setCommandQuery(commandText);
|
|
setShowCommandAutocomplete(true);
|
|
setShowFileMention(false);
|
|
setShowAgentAutocomplete(false);
|
|
setShowSkillAutocomplete(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowCommandAutocomplete(false);
|
|
|
|
const textBeforeCursor = value.substring(0, cursorPosition);
|
|
|
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
|
if (lastHashSymbol !== -1) {
|
|
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
|
|
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
|
|
const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n');
|
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
|
|
if (isWordBoundary && !hasSeparator) {
|
|
setAgentQuery(textAfterHash);
|
|
setShowAgentAutocomplete(true);
|
|
setShowFileMention(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowAgentAutocomplete(false);
|
|
setAgentQuery('');
|
|
|
|
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
|
if (lastSlashSymbol !== -1) {
|
|
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
|
|
const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1);
|
|
const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n');
|
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
|
|
if (isWordBoundary && !hasSeparator) {
|
|
setSkillQuery(textAfterSlash);
|
|
setShowSkillAutocomplete(true);
|
|
setShowFileMention(false);
|
|
setShowAgentAutocomplete(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setShowSkillAutocomplete(false);
|
|
setSkillQuery('');
|
|
|
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
|
if (lastAtSymbol !== -1) {
|
|
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
|
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
|
setMentionQuery(textAfterAt);
|
|
setShowFileMention(true);
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
|
|
|
const insertTextAtSelection = React.useCallback((text: string) => {
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) {
|
|
const nextValue = message + text;
|
|
setMessage(nextValue);
|
|
updateAutocompleteState(nextValue, nextValue.length);
|
|
requestAnimationFrame(() => adjustTextareaHeight());
|
|
return;
|
|
}
|
|
|
|
const start = textarea.selectionStart ?? message.length;
|
|
const end = textarea.selectionEnd ?? message.length;
|
|
const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`;
|
|
setMessage(nextValue);
|
|
const cursorPosition = start + text.length;
|
|
|
|
requestAnimationFrame(() => {
|
|
const currentTextarea = textareaRef.current;
|
|
if (currentTextarea) {
|
|
currentTextarea.selectionStart = cursorPosition;
|
|
currentTextarea.selectionEnd = cursorPosition;
|
|
}
|
|
adjustTextareaHeight();
|
|
});
|
|
|
|
updateAutocompleteState(nextValue, cursorPosition);
|
|
}, [adjustTextareaHeight, message, updateAutocompleteState]);
|
|
|
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const value = e.target.value;
|
|
const cursorPosition = e.target.selectionStart ?? value.length;
|
|
setMessage(value);
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(value, cursorPosition);
|
|
};
|
|
|
|
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
const fileMap = new Map<string, File>();
|
|
|
|
Array.from(e.clipboardData.files || []).forEach(file => {
|
|
if (file.type.startsWith('image/')) {
|
|
fileMap.set(`${file.name}-${file.size}`, file);
|
|
}
|
|
});
|
|
|
|
Array.from(e.clipboardData.items || []).forEach(item => {
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile();
|
|
if (file) {
|
|
fileMap.set(`${file.name}-${file.size}`, file);
|
|
}
|
|
}
|
|
});
|
|
|
|
const imageFiles = Array.from(fileMap.values());
|
|
if (imageFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!currentSessionId && !newSessionDraftOpen) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const pastedText = e.clipboardData.getData('text');
|
|
if (pastedText) {
|
|
insertTextAtSelection(pastedText);
|
|
}
|
|
|
|
let attachedCount = 0;
|
|
|
|
for (const file of imageFiles) {
|
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
|
try {
|
|
await addAttachedFile(file);
|
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
|
if (sizeAfter > sizeBefore) {
|
|
attachedCount += 1;
|
|
}
|
|
} catch (error) {
|
|
console.error('Clipboard image attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
|
|
}
|
|
}
|
|
|
|
if (attachedCount > 0) {
|
|
toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
|
|
}
|
|
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
|
|
|
|
const handleFileSelect = (file: { name: string; path: string }) => {
|
|
|
|
const cursorPosition = textareaRef.current?.selectionStart || 0;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
|
|
|
if (lastAtSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastAtSymbol) +
|
|
file.name +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
}
|
|
|
|
setShowFileMention(false);
|
|
setMentionQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleAgentSelect = (agentName: string) => {
|
|
const textarea = textareaRef.current;
|
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
|
|
|
if (lastHashSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastHashSymbol) +
|
|
`#${agentName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = lastHashSymbol + agentName.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
setShowAgentAutocomplete(false);
|
|
setAgentQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleSkillSelect = (skillName: string) => {
|
|
const textarea = textareaRef.current;
|
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
|
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
|
|
|
if (lastSlashSymbol !== -1) {
|
|
const newMessage =
|
|
message.substring(0, lastSlashSymbol) +
|
|
`${skillName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = lastSlashSymbol + skillName.length + 1;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
setShowSkillAutocomplete(false);
|
|
setSkillQuery('');
|
|
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
|
|
|
|
setMessage(`/${command.name} `);
|
|
|
|
const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command };
|
|
if (textareaElement) {
|
|
textareaElement._commandMetadata = command;
|
|
}
|
|
|
|
setShowCommandAutocomplete(false);
|
|
setCommandQuery('');
|
|
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (currentSessionId && textareaRef.current && !isMobile) {
|
|
textareaRef.current.focus();
|
|
}
|
|
}, [currentSessionId, isMobile]);
|
|
|
|
React.useEffect(() => {
|
|
if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) {
|
|
clearAbortPrompt();
|
|
}
|
|
}, [abortPromptSessionId, currentSessionId, clearAbortPrompt]);
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
|
|
setIsDragging(true);
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.currentTarget === e.target) {
|
|
setIsDragging(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
|
|
if (!currentSessionId && !newSessionDraftOpen) return;
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
let attachedCount = 0;
|
|
|
|
for (const file of files) {
|
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
|
try {
|
|
await addAttachedFile(file);
|
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
|
if (sizeAfter > sizeBefore) {
|
|
attachedCount += 1;
|
|
}
|
|
} catch (error) {
|
|
console.error('File attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
|
}
|
|
}
|
|
|
|
if (attachedCount > 0) {
|
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
|
}
|
|
};
|
|
|
|
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
|
|
let attachedCount = 0;
|
|
|
|
for (const file of files) {
|
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
|
try {
|
|
await addServerFile(file.path, file.name);
|
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
|
if (sizeAfter > sizeBefore) {
|
|
attachedCount += 1;
|
|
}
|
|
} catch (error) {
|
|
console.error('Server file attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
|
}
|
|
}
|
|
|
|
if (attachedCount > 0) {
|
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
|
}
|
|
}, [addServerFile]);
|
|
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
|
|
|
|
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
|
let attachedCount = 0;
|
|
const list = Array.isArray(files) ? files : Array.from(files);
|
|
|
|
for (const file of list) {
|
|
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
|
try {
|
|
await addAttachedFile(file);
|
|
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
|
if (sizeAfter > sizeBefore) {
|
|
attachedCount += 1;
|
|
}
|
|
} catch (error) {
|
|
console.error('File attach failed', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
|
}
|
|
}
|
|
|
|
if (attachedCount > 0) {
|
|
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
|
}
|
|
}, [addAttachedFile]);
|
|
|
|
const handleVSCodePickFiles = React.useCallback(async () => {
|
|
try {
|
|
const response = await fetch('/api/vscode/pick-files');
|
|
const data = await response.json();
|
|
const picked = Array.isArray(data?.files) ? data.files : [];
|
|
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
|
|
|
|
if (skipped.length > 0) {
|
|
const summary = skipped
|
|
.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`)
|
|
.join('\n');
|
|
toast.error(`Some files were skipped:\n${summary}`);
|
|
}
|
|
|
|
const asFiles = picked
|
|
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
|
|
if (!file?.dataUrl) return null;
|
|
try {
|
|
const [meta, base64] = file.dataUrl.split(',');
|
|
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
|
|
if (!base64) return null;
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
const blob = new Blob([bytes], { type: mime });
|
|
return new File([blob], file.name || 'file', { type: mime });
|
|
} catch (err) {
|
|
console.error('Failed to decode VS Code picked file', err);
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean) as File[];
|
|
|
|
if (asFiles.length > 0) {
|
|
await attachFiles(asFiles);
|
|
}
|
|
} catch (error) {
|
|
console.error('VS Code file pick failed', error);
|
|
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
|
|
}
|
|
}, [attachFiles]);
|
|
|
|
const handlePickLocalFiles = React.useCallback(() => {
|
|
if (isVSCodeRuntime()) {
|
|
void handleVSCodePickFiles();
|
|
return;
|
|
}
|
|
fileInputRef.current?.click();
|
|
}, [handleVSCodePickFiles]);
|
|
|
|
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = event.target.files;
|
|
if (!files) return;
|
|
await attachFiles(files);
|
|
event.target.value = '';
|
|
}, [attachFiles]);
|
|
|
|
const footerGapClass = isMobile ? 'gap-x-0.5 gap-y-0' : 'gap-x-1.5 gap-y-0';
|
|
const isVSCode = isVSCodeRuntime();
|
|
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
|
const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7');
|
|
const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
|
|
|
const iconButtonBaseClass = cn(
|
|
footerHeightClass,
|
|
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
|
|
);
|
|
|
|
const cancelButton = canAbort ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleAbort}
|
|
className={cn(
|
|
iconButtonBaseClass,
|
|
'text-[var(--status-error)] hover:text-[var(--status-error)]'
|
|
)}
|
|
aria-label="Stop generating"
|
|
>
|
|
<StopIcon className={cn(iconSizeClass)} />
|
|
</button>
|
|
) : null;
|
|
|
|
const sendButton = (
|
|
<button
|
|
type={isMobile ? 'button' : 'submit'}
|
|
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
|
onPointerDownCapture={(event) => {
|
|
if (!isMobile || event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
|
|
if (!canSend || (!currentSessionId && !newSessionDraftOpen)) {
|
|
return;
|
|
}
|
|
|
|
sendTriggeredByPointerDownRef.current = true;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void handleSubmit();
|
|
}}
|
|
onClick={(event) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
|
|
if (sendTriggeredByPointerDownRef.current) {
|
|
sendTriggeredByPointerDownRef.current = false;
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
void handleSubmit();
|
|
}}
|
|
className={cn(
|
|
iconButtonBaseClass,
|
|
canSend && (currentSessionId || newSessionDraftOpen)
|
|
? 'text-primary hover:text-primary'
|
|
: 'opacity-30'
|
|
)}
|
|
aria-label='Send message'
|
|
>
|
|
<RiSendPlane2Line className={cn(iconSizeClass)} />
|
|
</button>
|
|
);
|
|
|
|
const attachmentMenu = (
|
|
<>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleLocalFileSelect}
|
|
accept="*/*"
|
|
/>
|
|
|
|
<div className="relative inline-flex">
|
|
<ServerFilePicker
|
|
onFilesSelected={handleServerFilesSelected}
|
|
multiSelect
|
|
presentation={isMobile ? 'modal' : 'dropdown'}
|
|
open={projectFilePickerOpen}
|
|
onOpenChange={setProjectFilePickerOpen}
|
|
>
|
|
{isMobile ? null : (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
className="absolute inset-0 opacity-0 pointer-events-none"
|
|
/>
|
|
)}
|
|
</ServerFilePicker>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={iconButtonBaseClass}
|
|
title="Add attachment"
|
|
aria-label="Add attachment"
|
|
>
|
|
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
requestAnimationFrame(() => handlePickLocalFiles());
|
|
}}
|
|
>
|
|
<RiAttachment2 />
|
|
Attach files
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
requestAnimationFrame(() => {
|
|
setProjectFilePickerOpen(true);
|
|
});
|
|
}}
|
|
>
|
|
<RiFileUploadLine />
|
|
Attach from project
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const settingsButton = onOpenSettings ? (
|
|
<button
|
|
type='button'
|
|
onClick={onOpenSettings}
|
|
className={iconButtonBaseClass}
|
|
title='Model and agent settings'
|
|
aria-label='Model and agent settings'
|
|
>
|
|
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
) : null;
|
|
|
|
const attachmentsControls = (
|
|
<>
|
|
{attachmentMenu}
|
|
{settingsButton}
|
|
</>
|
|
);
|
|
|
|
const workingStatusText = working.statusText;
|
|
|
|
React.useEffect(() => {
|
|
const pendingAbortBanner = Boolean(working.wasAborted);
|
|
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
|
startAbortIndicator();
|
|
if (currentSessionId) {
|
|
acknowledgeSessionAbort(currentSessionId);
|
|
}
|
|
}
|
|
prevWasAbortedRef.current = pendingAbortBanner;
|
|
}, [
|
|
acknowledgeSessionAbort,
|
|
currentSessionId,
|
|
showAbortStatus,
|
|
startAbortIndicator,
|
|
working.wasAborted,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (abortTimeoutRef.current) {
|
|
clearTimeout(abortTimeoutRef.current);
|
|
abortTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className={cn(
|
|
"relative pt-0 pb-2 md:pb-4",
|
|
isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area"
|
|
)}
|
|
data-keyboard-avoid="true"
|
|
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
|
>
|
|
{/* Absolute positioned above input - no layout shift */}
|
|
<div className="absolute bottom-full left-0 right-0">
|
|
<StatusRow
|
|
isWorking={working.isWorking}
|
|
statusText={workingStatusText}
|
|
isGenericStatus={working.isGenericStatus}
|
|
isWaitingForPermission={working.isWaitingForPermission}
|
|
wasAborted={working.wasAborted}
|
|
abortActive={working.abortActive}
|
|
completionId={working.lastCompletionId}
|
|
isComplete={working.isComplete}
|
|
showAbortStatus={showAbortStatus}
|
|
/>
|
|
</div>
|
|
<div
|
|
ref={dropZoneRef}
|
|
className={cn(
|
|
"chat-column relative overflow-visible",
|
|
isDragging && "ring-2 ring-primary ring-offset-2 rounded-xl"
|
|
)}
|
|
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
{isDragging && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
|
<div className="text-center">
|
|
<div className="inline-flex justify-center">
|
|
<button
|
|
type="button"
|
|
className={iconButtonBaseClass}
|
|
onClick={() => handlePickLocalFiles()}
|
|
title="Attach files"
|
|
aria-label="Attach files"
|
|
>
|
|
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
</div>
|
|
<p className="mt-2 typography-ui-label text-muted-foreground">Drop files here to attach</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<AttachedFilesList />
|
|
<QueuedMessageChips
|
|
onEditMessage={(content) => {
|
|
setMessage(content);
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"border border-border/80 bg-input/10 dark:bg-input/30",
|
|
"flex flex-col relative overflow-visible"
|
|
)}
|
|
style={chatInputWrapperStyle}
|
|
>
|
|
{}
|
|
{showCommandAutocomplete && (
|
|
<CommandAutocomplete
|
|
ref={commandRef}
|
|
searchQuery={commandQuery}
|
|
onCommandSelect={handleCommandSelect}
|
|
onClose={() => setShowCommandAutocomplete(false)}
|
|
/>
|
|
)}
|
|
{}
|
|
{showAgentAutocomplete && (
|
|
<AgentMentionAutocomplete
|
|
ref={agentRef}
|
|
searchQuery={agentQuery}
|
|
onAgentSelect={handleAgentSelect}
|
|
onClose={() => setShowAgentAutocomplete(false)}
|
|
/>
|
|
)}
|
|
|
|
{showSkillAutocomplete && (
|
|
<SkillAutocomplete
|
|
ref={skillRef}
|
|
searchQuery={skillQuery}
|
|
onSkillSelect={handleSkillSelect}
|
|
onClose={() => setShowSkillAutocomplete(false)}
|
|
/>
|
|
)}
|
|
|
|
{showFileMention && (
|
|
|
|
<FileMentionAutocomplete
|
|
ref={mentionRef}
|
|
searchQuery={mentionQuery}
|
|
onFileSelect={handleFileSelect}
|
|
onClose={() => setShowFileMention(false)}
|
|
/>
|
|
)}
|
|
<Textarea
|
|
ref={textareaRef}
|
|
data-chat-input="true"
|
|
value={message}
|
|
onChange={handleTextChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onPointerDownCapture={handleTextareaPointerDownCapture}
|
|
placeholder={currentSessionId || newSessionDraftOpen
|
|
? "# for agents; @ for files; / for commands"
|
|
: "Select or create a session to start chatting"}
|
|
disabled={!currentSessionId && !newSessionDraftOpen}
|
|
|
|
className={cn(
|
|
'min-h-[52px] resize-none border-0 px-3 shadow-none rounded-b-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent',
|
|
isMobile ? "py-2.5" : "pt-4 pb-2",
|
|
"focus-visible:outline-none focus-visible:ring-0"
|
|
)}
|
|
style={{
|
|
flex: 'none',
|
|
height: textareaSize ? `${textareaSize.height}px` : undefined,
|
|
maxHeight: textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
|
borderTopLeftRadius: cornerRadius,
|
|
borderTopRightRadius: cornerRadius,
|
|
}}
|
|
rows={1}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
'bg-transparent',
|
|
footerPaddingClass,
|
|
cn('flex items-center justify-between', footerGapClass)
|
|
)}
|
|
style={{
|
|
borderBottomLeftRadius: cornerRadius,
|
|
borderBottomRightRadius: cornerRadius,
|
|
}}
|
|
data-chat-input-footer="true"
|
|
>
|
|
<div className={cn('flex items-center min-w-0 flex-1', footerGapClass)}>
|
|
<div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
|
|
{attachmentsControls}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 overflow-hidden">
|
|
<ModelControls className={cn('w-full min-w-0')} />
|
|
</div>
|
|
</div>
|
|
<div className={cn('flex items-center flex-shrink-0', footerGapClass)}>
|
|
{cancelButton}
|
|
{sendButton}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|