- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1177 lines
50 KiB
TypeScript
1177 lines
50 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import TextareaAutosize from 'react-textarea-autosize';
|
|
import { Button } from './ui/button';
|
|
import { ChatInputContainer, ButtonFooter, AttachButton, RecordButton } from './ChatInput';
|
|
import ModelPickerModal from './ModelPicker';
|
|
import {
|
|
SendHorizontal,
|
|
X,
|
|
Loader2,
|
|
AlertCircle,
|
|
Square,
|
|
FileAudio,
|
|
File,
|
|
Brain,
|
|
} from 'lucide-react';
|
|
import { Alert, AlertDescription } from './ui/alert';
|
|
import { useChatContext } from './hooks/ChatContext';
|
|
import { useFontsReady } from './hooks/useFontsReady';
|
|
import { cn, filterAndSortResources } from '../lib/utils';
|
|
import { useCurrentSessionId, useSessionProcessing } from '@/lib/stores';
|
|
import { useCurrentLLM } from './hooks/useCurrentLLM';
|
|
import ResourceAutocomplete from './ResourceAutocomplete';
|
|
import type { ResourceMetadata as UIResourceMetadata } from '@dexto/core';
|
|
import { useResources } from './hooks/useResources';
|
|
import SlashCommandAutocomplete from './SlashCommandAutocomplete';
|
|
import { isTextPart, isImagePart, isFilePart } from '../types';
|
|
import CreatePromptModal from './CreatePromptModal';
|
|
import CreateMemoryModal from './CreateMemoryModal';
|
|
import { parseSlashInput, splitKeyValueAndPositional } from '../lib/parseSlash';
|
|
import { useAnalytics } from '@/lib/analytics/index.js';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { queryKeys } from '@/lib/queryKeys';
|
|
import { useModelCapabilities } from './hooks/useLLM';
|
|
import { useResolvePrompt } from './hooks/usePrompts';
|
|
import { useInputHistory } from './hooks/useInputHistory';
|
|
import { useQueuedMessages, useRemoveQueuedMessage, useQueueMessage } from './hooks/useQueue';
|
|
import { QueuedMessagesDisplay } from './QueuedMessagesDisplay';
|
|
|
|
interface InputAreaProps {
|
|
onSend: (
|
|
content: string,
|
|
imageData?: { image: string; mimeType: string },
|
|
fileData?: { data: string; mimeType: string; filename?: string }
|
|
) => void;
|
|
isSending?: boolean;
|
|
variant?: 'welcome' | 'chat';
|
|
isSessionsPanelOpen?: boolean;
|
|
}
|
|
|
|
export default function InputArea({
|
|
onSend,
|
|
isSending,
|
|
isSessionsPanelOpen = false,
|
|
}: InputAreaProps) {
|
|
const queryClient = useQueryClient();
|
|
const [text, setText] = useState('');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const [imageData, setImageData] = useState<{ image: string; mimeType: string } | null>(null);
|
|
const [fileData, setFileData] = useState<{
|
|
data: string;
|
|
mimeType: string;
|
|
filename?: string;
|
|
} | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
|
const audioInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// TODO(unify-fonts): Defer autosize until fonts are ready to avoid
|
|
// initial one-line height jump due to font swap metrics. Remove this
|
|
// once the app uses a single font pipeline without swap.
|
|
// Currently it looks like only 'Welcome to Dexto' is using the older font - (checked with chrome dev tools)
|
|
const fontsReady = useFontsReady();
|
|
|
|
// Audio recording state
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
|
|
// Get state from centralized selectors
|
|
const currentSessionId = useCurrentSessionId();
|
|
const processing = useSessionProcessing(currentSessionId);
|
|
|
|
// Get actions from ChatContext
|
|
const { cancel } = useChatContext();
|
|
|
|
// Get state from stores and hooks
|
|
const { data: currentLLM } = useCurrentLLM(currentSessionId);
|
|
|
|
// Input history for Up/Down navigation
|
|
const { invalidateHistory, navigateUp, navigateDown, resetCursor, isBrowsing } =
|
|
useInputHistory(currentSessionId);
|
|
|
|
// Queue management
|
|
const { data: queueData } = useQueuedMessages(currentSessionId);
|
|
const { mutate: removeQueuedMessage } = useRemoveQueuedMessage();
|
|
const { mutate: queueMessage } = useQueueMessage();
|
|
const queuedMessages = useMemo(() => queueData?.messages ?? [], [queueData?.messages]);
|
|
|
|
// Analytics tracking
|
|
const analytics = useAnalytics();
|
|
const analyticsRef = useRef(analytics);
|
|
|
|
// Keep analytics ref up to date to avoid stale closure issues
|
|
useEffect(() => {
|
|
analyticsRef.current = analytics;
|
|
}, [analytics]);
|
|
|
|
// LLM selector state
|
|
const [modelSwitchError, setModelSwitchError] = useState<string | null>(null);
|
|
const [fileUploadError, setFileUploadError] = useState<string | null>(null);
|
|
const [supportedFileTypes, setSupportedFileTypes] = useState<string[]>([]);
|
|
|
|
// Resources (for @ mention autocomplete)
|
|
const { resources, loading: resourcesLoading, refresh: refreshResources } = useResources();
|
|
const [mentionQuery, setMentionQuery] = useState('');
|
|
const [showMention, setShowMention] = useState(false);
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties | null>(null);
|
|
|
|
// Memoize filtered resources to avoid re-sorting on every keypress
|
|
const filteredResources = useMemo(
|
|
() => filterAndSortResources(resources, mentionQuery),
|
|
[resources, mentionQuery]
|
|
);
|
|
|
|
const findActiveAtIndex = (value: string, caret: number) => {
|
|
// Walk backwards from caret to find an '@'
|
|
// @ is only valid if:
|
|
// 1. At the start of the message (i === 0), OR
|
|
// 2. Preceded by whitespace
|
|
for (let i = caret - 1; i >= 0; i--) {
|
|
const ch = value[i];
|
|
if (ch === '@') {
|
|
// Check if @ is at start or preceded by whitespace
|
|
if (i === 0) {
|
|
return i; // @ at start is valid
|
|
}
|
|
const prev = value[i - 1];
|
|
if (/\s/.test(prev)) {
|
|
return i; // @ after whitespace is valid
|
|
}
|
|
return -1; // @ in middle of text (like email) - ignore
|
|
}
|
|
if (/\s/.test(ch)) break; // stop at whitespace
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
// File size limit (64MB)
|
|
const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64MB in bytes
|
|
|
|
// Slash command state
|
|
const [showSlashCommands, setShowSlashCommands] = useState(false);
|
|
const [showCreatePromptModal, setShowCreatePromptModal] = useState(false);
|
|
const [slashRefreshKey, setSlashRefreshKey] = useState(0);
|
|
|
|
// Memory state
|
|
const [showCreateMemoryModal, setShowCreateMemoryModal] = useState(false);
|
|
|
|
// Prompt resolution mutation
|
|
const resolvePromptMutation = useResolvePrompt();
|
|
|
|
const showUserError = (message: string) => {
|
|
setFileUploadError(message);
|
|
// Auto-clear error after 5 seconds
|
|
setTimeout(() => setFileUploadError(null), 5000);
|
|
};
|
|
|
|
const openCreatePromptModal = React.useCallback(() => {
|
|
setShowSlashCommands(false);
|
|
setShowCreatePromptModal(true);
|
|
}, []);
|
|
|
|
const handlePromptCreated = React.useCallback(
|
|
(prompt: { name: string; arguments?: Array<{ name: string; required?: boolean }> }) => {
|
|
// Manual cache invalidation needed for custom prompt creation (not triggered by SSE events)
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
|
setShowCreatePromptModal(false);
|
|
setSlashRefreshKey((prev) => prev + 1);
|
|
const slashCommand = `/${prompt.name}`;
|
|
setText(slashCommand);
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.setSelectionRange(slashCommand.length, slashCommand.length);
|
|
}
|
|
},
|
|
[queryClient]
|
|
);
|
|
|
|
const handleCloseCreatePrompt = React.useCallback(() => {
|
|
setShowCreatePromptModal(false);
|
|
if (text === '/') {
|
|
setText('');
|
|
}
|
|
}, [text]);
|
|
|
|
// Fetch model capabilities (supported file types) via dedicated endpoint
|
|
// This handles gateway providers (dexto, openrouter) by resolving to underlying model capabilities
|
|
const { data: capabilities } = useModelCapabilities(currentLLM?.provider, currentLLM?.model);
|
|
|
|
// Extract supported file types from capabilities
|
|
useEffect(() => {
|
|
if (capabilities?.supportedFileTypes) {
|
|
setSupportedFileTypes(capabilities.supportedFileTypes);
|
|
} else {
|
|
setSupportedFileTypes([]);
|
|
}
|
|
}, [capabilities]);
|
|
|
|
// NOTE: We intentionally do not manually resize the textarea. We rely on
|
|
// CSS max-height + overflow to keep layout stable.
|
|
|
|
const handleSend = async () => {
|
|
let trimmed = text.trim();
|
|
// Allow sending if we have text OR any attachment
|
|
if (!trimmed && !imageData && !fileData) return;
|
|
|
|
// If slash command typed, resolve to full prompt content at send time
|
|
if (trimmed === '/') {
|
|
openCreatePromptModal();
|
|
return;
|
|
} else if (trimmed.startsWith('/')) {
|
|
const parsed = parseSlashInput(trimmed);
|
|
const name = parsed.command;
|
|
// Preserve original suffix including quotes/spacing (trim only leading space)
|
|
const originalArgsText = trimmed.slice(1 + name.length).trimStart();
|
|
if (name) {
|
|
try {
|
|
const result = await resolvePromptMutation.mutateAsync({
|
|
name,
|
|
context: originalArgsText || undefined,
|
|
args:
|
|
parsed.argsArray && parsed.argsArray.length > 0
|
|
? (() => {
|
|
const { keyValues, positional } = splitKeyValueAndPositional(
|
|
parsed.argsArray
|
|
);
|
|
const argsPayload: Record<string, unknown> = { ...keyValues };
|
|
if (positional.length > 0)
|
|
argsPayload._positional = positional;
|
|
return Object.keys(argsPayload).length > 0
|
|
? JSON.stringify(argsPayload)
|
|
: undefined;
|
|
})()
|
|
: undefined,
|
|
});
|
|
if (result.text.trim()) {
|
|
trimmed = result.text;
|
|
}
|
|
} catch {
|
|
// keep original
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-queue: if session is busy processing, queue the message instead of sending
|
|
if (processing && currentSessionId) {
|
|
queueMessage({
|
|
sessionId: currentSessionId,
|
|
message: trimmed || undefined,
|
|
imageData: imageData ?? undefined,
|
|
fileData: fileData ?? undefined,
|
|
});
|
|
// Invalidate history cache so it refetches with new message
|
|
invalidateHistory();
|
|
setText('');
|
|
setImageData(null);
|
|
setFileData(null);
|
|
setShowSlashCommands(false);
|
|
// Keep focus in input for quick follow-up messages
|
|
textareaRef.current?.focus();
|
|
return;
|
|
}
|
|
|
|
onSend(trimmed, imageData ?? undefined, fileData ?? undefined);
|
|
// Invalidate history cache so it refetches with new message
|
|
invalidateHistory();
|
|
setText('');
|
|
setImageData(null);
|
|
setFileData(null);
|
|
// Ensure guidance window closes after submit
|
|
setShowSlashCommands(false);
|
|
// Keep focus in input for quick follow-up messages
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const applyMentionSelection = (index: number, selectedResource?: UIResourceMetadata) => {
|
|
if (!selectedResource && filteredResources.length === 0) return;
|
|
const selected =
|
|
selectedResource ??
|
|
filteredResources[Math.max(0, Math.min(index, filteredResources.length - 1))];
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const caret = ta.selectionStart ?? text.length;
|
|
const atIndex = findActiveAtIndex(text, caret);
|
|
if (atIndex === -1) return;
|
|
const before = text.slice(0, atIndex);
|
|
const after = text.slice(caret);
|
|
// Mask input with readable name, rely on runtime resolver for expansion
|
|
const name = selected.name || selected.uri.split('/').pop() || selected.uri;
|
|
const insertion = selected.serverName ? `@${selected.serverName}:${name}` : `@${name}`;
|
|
const next = before + insertion + after;
|
|
setText(next);
|
|
setShowMention(false);
|
|
setMentionQuery('');
|
|
setMentionIndex(0);
|
|
// Restore caret after inserted mention
|
|
requestAnimationFrame(() => {
|
|
const pos = (before + insertion).length;
|
|
ta.setSelectionRange(pos, pos);
|
|
ta.focus();
|
|
});
|
|
};
|
|
|
|
// Edit a queued message: remove from queue and load into input
|
|
const handleEditQueuedMessage = useCallback(
|
|
(message: (typeof queuedMessages)[number]) => {
|
|
if (!currentSessionId) return;
|
|
|
|
// Extract text content from message
|
|
const textContent = message.content
|
|
.filter(isTextPart)
|
|
.map((part) => part.text)
|
|
.join('\n');
|
|
|
|
// Extract image attachment if present
|
|
const imagePart = message.content.find(isImagePart);
|
|
|
|
// Extract file attachment if present
|
|
const filePart = message.content.find(isFilePart);
|
|
|
|
// Load into input
|
|
setText(textContent);
|
|
setImageData(
|
|
imagePart
|
|
? { image: imagePart.image, mimeType: imagePart.mimeType ?? 'image/jpeg' }
|
|
: null
|
|
);
|
|
setFileData(
|
|
filePart
|
|
? {
|
|
data: filePart.data,
|
|
mimeType: filePart.mimeType,
|
|
filename: filePart.filename,
|
|
}
|
|
: null
|
|
);
|
|
|
|
// Remove from queue
|
|
removeQueuedMessage({ sessionId: currentSessionId, messageId: message.id });
|
|
|
|
// Focus textarea
|
|
textareaRef.current?.focus();
|
|
},
|
|
[currentSessionId, removeQueuedMessage]
|
|
);
|
|
|
|
// Handle Up arrow to edit most recent queued message (when input empty/on first line)
|
|
const handleEditLastQueued = useCallback(() => {
|
|
if (queuedMessages.length === 0) return false;
|
|
|
|
// Get the most recently queued message (last in array)
|
|
const lastMessage = queuedMessages[queuedMessages.length - 1];
|
|
if (lastMessage) {
|
|
handleEditQueuedMessage(lastMessage);
|
|
return true;
|
|
}
|
|
return false;
|
|
}, [queuedMessages, handleEditQueuedMessage]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// If mention menu open, handle navigation
|
|
if (showMention) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setMentionIndex((prev) => (prev + 1) % Math.max(1, filteredResources.length));
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setMentionIndex(
|
|
(prev) =>
|
|
(prev - 1 + Math.max(1, filteredResources.length)) %
|
|
Math.max(1, filteredResources.length)
|
|
);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
applyMentionSelection(mentionIndex);
|
|
return;
|
|
}
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
applyMentionSelection(mentionIndex);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setShowMention(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Up: First check queue, then fall back to input history
|
|
// Only handle when cursor is on first line (no newline before cursor)
|
|
if (e.key === 'ArrowUp' && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
const cursorPos = textareaRef.current?.selectionStart ?? 0;
|
|
const textBeforeCursor = text.slice(0, cursorPos);
|
|
const isOnFirstLine = !textBeforeCursor.includes('\n');
|
|
|
|
if (isOnFirstLine) {
|
|
e.preventDefault();
|
|
// First priority: pop from queue if available
|
|
if (queuedMessages.length > 0) {
|
|
handleEditLastQueued();
|
|
return;
|
|
}
|
|
// Second priority: navigate input history
|
|
const historyText = navigateUp(text);
|
|
if (historyText !== null) {
|
|
setText(historyText);
|
|
// Move cursor to end
|
|
requestAnimationFrame(() => {
|
|
const len = historyText.length;
|
|
textareaRef.current?.setSelectionRange(len, len);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'ArrowDown' && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
if (isBrowsing) {
|
|
e.preventDefault();
|
|
const historyText = navigateDown();
|
|
if (historyText !== null) {
|
|
setText(historyText);
|
|
// Move cursor to end
|
|
requestAnimationFrame(() => {
|
|
const len = historyText.length;
|
|
textareaRef.current?.setSelectionRange(len, len);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If memory hint is showing, handle Escape to dismiss
|
|
if (showMemoryHint && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setShowMemoryHint(false);
|
|
setText('');
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
// Check if user typed `#` to create a memory
|
|
if (text.trim() === '#') {
|
|
setText('');
|
|
setShowMemoryHint(false);
|
|
setShowCreateMemoryModal(true);
|
|
return;
|
|
}
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
// Memory hint state
|
|
const [showMemoryHint, setShowMemoryHint] = useState(false);
|
|
const [memoryHintStyle, setMemoryHintStyle] = useState<React.CSSProperties | null>(null);
|
|
|
|
// Handle slash command input
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const value = e.target.value;
|
|
setText(value);
|
|
|
|
// Reset history browsing when user types
|
|
resetCursor();
|
|
|
|
// Guidance UX: keep slash guidance window open while the user is constructing
|
|
// a slash command (i.e., as long as the input starts with '/' and has no newline).
|
|
// This lets users see argument hints while typing positional/named args.
|
|
if (value.startsWith('/') && !value.includes('\n')) {
|
|
setShowSlashCommands(true);
|
|
} else if (showSlashCommands) {
|
|
setShowSlashCommands(false);
|
|
}
|
|
|
|
// Show memory hint when user types exactly '#'
|
|
if (value.trim() === '#') {
|
|
setShowMemoryHint(true);
|
|
// Position hint below textarea
|
|
const ta = textareaRef.current;
|
|
if (ta) {
|
|
const anchor = ta.getBoundingClientRect();
|
|
const margin = 16;
|
|
const left = Math.max(8, anchor.left + window.scrollX + margin);
|
|
const maxWidth = Math.max(280, anchor.width - margin * 2);
|
|
const bottomOffset = 64;
|
|
const bottom = Math.max(
|
|
80,
|
|
window.innerHeight - (anchor.bottom + window.scrollY) + bottomOffset
|
|
);
|
|
setMemoryHintStyle({
|
|
position: 'fixed',
|
|
left,
|
|
bottom,
|
|
width: maxWidth,
|
|
zIndex: 9999,
|
|
});
|
|
}
|
|
} else {
|
|
setShowMemoryHint(false);
|
|
setMemoryHintStyle(null);
|
|
}
|
|
};
|
|
|
|
// Handle prompt selection
|
|
const handlePromptSelect = (prompt: {
|
|
name: string;
|
|
arguments?: Array<{ name: string; required?: boolean }>;
|
|
}) => {
|
|
const slash = `/${prompt.name}`;
|
|
setText(slash);
|
|
setShowSlashCommands(false);
|
|
if (textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.setSelectionRange(slash.length, slash.length);
|
|
}
|
|
};
|
|
|
|
const closeSlashCommands = () => {
|
|
setShowSlashCommands(false);
|
|
};
|
|
|
|
// Detect @mention context on text change and caret move
|
|
useEffect(() => {
|
|
const ta = textareaRef.current;
|
|
const caret = ta ? (ta.selectionStart ?? text.length) : text.length;
|
|
const atIndex = findActiveAtIndex(text, caret);
|
|
if (atIndex >= 0) {
|
|
const q = text.slice(atIndex + 1, caret);
|
|
setMentionQuery(q);
|
|
setShowMention(true);
|
|
setMentionIndex(0);
|
|
// Compute dropdown viewport position via textarea's bounding rect
|
|
const anchor = ta?.getBoundingClientRect();
|
|
if (anchor) {
|
|
const margin = 16; // inner padding from InputArea
|
|
const left = Math.max(8, anchor.left + window.scrollX + margin);
|
|
const maxWidth = Math.max(280, anchor.width - margin * 2);
|
|
const bottomOffset = 64; // keep above footer area
|
|
const bottom = Math.max(
|
|
80,
|
|
window.innerHeight - (anchor.bottom + window.scrollY) + bottomOffset
|
|
);
|
|
setDropdownStyle({
|
|
position: 'fixed',
|
|
left,
|
|
bottom,
|
|
width: maxWidth,
|
|
zIndex: 9999,
|
|
});
|
|
}
|
|
} else {
|
|
setShowMention(false);
|
|
setMentionQuery('');
|
|
setDropdownStyle(null);
|
|
}
|
|
}, [text]);
|
|
|
|
const mentionActiveRef = React.useRef(false);
|
|
useEffect(() => {
|
|
if (showMention) {
|
|
if (!mentionActiveRef.current) {
|
|
mentionActiveRef.current = true;
|
|
void refreshResources();
|
|
}
|
|
} else {
|
|
mentionActiveRef.current = false;
|
|
}
|
|
}, [showMention, refreshResources]);
|
|
|
|
// Large paste guard to prevent layout from exploding with very large text
|
|
const LARGE_PASTE_THRESHOLD = 20000; // characters
|
|
const toBase64 = (str: string) => {
|
|
try {
|
|
return btoa(unescape(encodeURIComponent(str)));
|
|
} catch {
|
|
return btoa(str);
|
|
}
|
|
};
|
|
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
const pasted = e.clipboardData.getData('text/plain');
|
|
if (!pasted) return;
|
|
if (pasted.length <= LARGE_PASTE_THRESHOLD) return;
|
|
e.preventDefault();
|
|
const attach = window.confirm(
|
|
'Large text detected. Attach as a file instead of inflating the input?\n(OK = attach as file, Cancel = paste truncated preview)'
|
|
);
|
|
if (attach) {
|
|
setFileData({
|
|
data: toBase64(pasted),
|
|
mimeType: 'text/plain',
|
|
filename: 'pasted.txt',
|
|
});
|
|
} else {
|
|
const preview = pasted.slice(0, LARGE_PASTE_THRESHOLD);
|
|
setText((prev) => prev + preview);
|
|
}
|
|
};
|
|
|
|
const handlePdfChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// File size validation
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
showUserError('PDF file too large. Maximum size is 64MB.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
if (file.type !== 'application/pdf') {
|
|
showUserError('Please select a valid PDF file.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
try {
|
|
const result = reader.result as string;
|
|
const commaIndex = result.indexOf(',');
|
|
const data = result.substring(commaIndex + 1);
|
|
setFileData({ data, mimeType: 'application/pdf', filename: file.name });
|
|
setFileUploadError(null); // Clear any previous errors
|
|
|
|
// Track file upload
|
|
if (currentSessionId) {
|
|
analyticsRef.current.trackFileAttached({
|
|
fileType: 'application/pdf',
|
|
fileSizeBytes: file.size,
|
|
sessionId: currentSessionId,
|
|
});
|
|
}
|
|
} catch {
|
|
showUserError('Failed to process PDF file. Please try again.');
|
|
setFileData(null);
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
showUserError('Failed to read PDF file. Please try again.');
|
|
setFileData(null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
e.target.value = '';
|
|
};
|
|
|
|
// Audio Recording Handlers
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream);
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
const chunks: BlobPart[] = [];
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) chunks.push(event.data);
|
|
};
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = new Blob(chunks, { type: mediaRecorder.mimeType });
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
try {
|
|
const result = reader.result as string;
|
|
const commaIndex = result.indexOf(',');
|
|
const data = result.substring(commaIndex + 1);
|
|
// Preserve original MIME type and determine appropriate extension
|
|
const mimeType = mediaRecorder.mimeType || 'audio/webm';
|
|
const getExtensionFromMime = (mime: string): string => {
|
|
const mimeToExt: Record<string, string> = {
|
|
'audio/mp3': 'mp3',
|
|
'audio/mpeg': 'mp3',
|
|
'audio/wav': 'wav',
|
|
'audio/x-wav': 'wav',
|
|
'audio/wave': 'wav',
|
|
'audio/webm': 'webm',
|
|
'audio/ogg': 'ogg',
|
|
'audio/m4a': 'm4a',
|
|
'audio/aac': 'aac',
|
|
};
|
|
return mimeToExt[mime] || mime.split('/')[1] || 'webm';
|
|
};
|
|
const ext = getExtensionFromMime(mimeType);
|
|
|
|
setFileData({
|
|
data,
|
|
mimeType: mimeType,
|
|
filename: `recording.${ext}`,
|
|
});
|
|
|
|
// Track audio recording upload
|
|
if (currentSessionId) {
|
|
analyticsRef.current.trackFileAttached({
|
|
fileType: mimeType,
|
|
fileSizeBytes: blob.size,
|
|
sessionId: currentSessionId,
|
|
});
|
|
}
|
|
} catch {
|
|
showUserError('Failed to process audio recording. Please try again.');
|
|
setFileData(null);
|
|
}
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
|
|
// Stop all tracks to release microphone
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
};
|
|
|
|
mediaRecorder.start();
|
|
setIsRecording(true);
|
|
} catch {
|
|
showUserError('Failed to start audio recording. Please check microphone permissions.');
|
|
}
|
|
};
|
|
|
|
const stopRecording = () => {
|
|
mediaRecorderRef.current?.stop();
|
|
setIsRecording(false);
|
|
};
|
|
|
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// File size validation
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
showUserError('Image file too large. Maximum size is 64MB.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
showUserError('Please select a valid image file.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
try {
|
|
const result = reader.result as string;
|
|
const commaIndex = result.indexOf(',');
|
|
if (commaIndex === -1) throw new Error('Invalid Data URL format');
|
|
|
|
const meta = result.substring(0, commaIndex);
|
|
const image = result.substring(commaIndex + 1);
|
|
|
|
const mimeMatch = meta.match(/data:(.*);base64/);
|
|
const mimeType = mimeMatch ? mimeMatch[1] : file.type;
|
|
|
|
if (!mimeType) throw new Error('Could not determine MIME type');
|
|
|
|
setImageData({ image, mimeType });
|
|
setFileUploadError(null); // Clear any previous errors
|
|
|
|
// Track image upload
|
|
if (currentSessionId) {
|
|
analyticsRef.current.trackImageAttached({
|
|
imageType: mimeType,
|
|
imageSizeBytes: file.size,
|
|
sessionId: currentSessionId,
|
|
});
|
|
}
|
|
} catch {
|
|
showUserError('Failed to process image file. Please try again.');
|
|
setImageData(null);
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
showUserError('Failed to read image file. Please try again.');
|
|
setImageData(null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
e.target.value = '';
|
|
};
|
|
|
|
const removeImage = () => setImageData(null);
|
|
|
|
const triggerFileInput = () => fileInputRef.current?.click();
|
|
const triggerPdfInput = () => pdfInputRef.current?.click();
|
|
const triggerAudioInput = () => audioInputRef.current?.click();
|
|
|
|
// Clear model switch error when user starts typing
|
|
useEffect(() => {
|
|
if (text && modelSwitchError) {
|
|
setModelSwitchError(null);
|
|
}
|
|
if (text && fileUploadError) {
|
|
setFileUploadError(null);
|
|
}
|
|
}, [text, modelSwitchError, fileUploadError]);
|
|
|
|
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// File size validation
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
showUserError('Audio file too large. Maximum size is 64MB.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
if (!file.type.startsWith('audio/')) {
|
|
showUserError('Please select a valid audio file.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
try {
|
|
const result = reader.result as string;
|
|
const commaIndex = result.indexOf(',');
|
|
const data = result.substring(commaIndex + 1);
|
|
// Preserve original MIME type from file
|
|
setFileData({ data, mimeType: file.type, filename: file.name });
|
|
setFileUploadError(null); // Clear any previous errors
|
|
|
|
// Track file upload
|
|
if (currentSessionId) {
|
|
analyticsRef.current.trackFileAttached({
|
|
fileType: file.type,
|
|
fileSizeBytes: file.size,
|
|
sessionId: currentSessionId,
|
|
});
|
|
}
|
|
} catch {
|
|
showUserError('Failed to process audio file. Please try again.');
|
|
setFileData(null);
|
|
}
|
|
};
|
|
reader.onerror = () => {
|
|
showUserError('Failed to read audio file. Please try again.');
|
|
setFileData(null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
e.target.value = '';
|
|
};
|
|
|
|
// Unified input panel: use the same full-featured chat composer in both welcome and chat states
|
|
|
|
// Chat variant - full featured input area
|
|
return (
|
|
<div id="input-area" className="flex flex-col gap-2 w-full">
|
|
{/* Model Switch Error Alert */}
|
|
{modelSwitchError && (
|
|
<Alert variant="destructive" className="mb-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="flex items-center justify-between">
|
|
<span>{modelSwitchError}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setModelSwitchError(null)}
|
|
className="h-auto p-1 ml-2"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* File Upload Error Alert */}
|
|
{fileUploadError && (
|
|
<Alert variant="destructive" className="mb-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="flex items-center justify-between">
|
|
<span>{fileUploadError}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setFileUploadError(null)}
|
|
className="h-auto p-1 ml-2"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="w-full">
|
|
{/* Unified pill input with send button */}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}}
|
|
>
|
|
<ChatInputContainer>
|
|
{/* Queued messages display (shows when messages are pending) */}
|
|
{queuedMessages.length > 0 && (
|
|
<QueuedMessagesDisplay
|
|
messages={queuedMessages}
|
|
onEditMessage={handleEditQueuedMessage}
|
|
onRemoveMessage={(messageId) => {
|
|
if (currentSessionId) {
|
|
removeQueuedMessage({
|
|
sessionId: currentSessionId,
|
|
messageId,
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Attachments strip (inside bubble, above editor) */}
|
|
{(imageData || fileData) && (
|
|
<div className="px-4 pt-4">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{imageData && (
|
|
<div className="relative w-fit border border-border rounded-lg p-1 bg-muted/50 group">
|
|
<img
|
|
src={`data:${imageData.mimeType};base64,${imageData.image}`}
|
|
alt="preview"
|
|
className="h-12 w-auto rounded-md"
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={removeImage}
|
|
className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-destructive-foreground opacity-100 group-hover:opacity-100 transition-opacity duration-150 shadow-md"
|
|
aria-label="Remove image"
|
|
>
|
|
<X className="h-2 w-2" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{fileData && (
|
|
<div className="relative w-fit border border-border rounded-lg p-2 bg-muted/50 flex items-center gap-2 group">
|
|
{fileData.mimeType.startsWith('audio') ? (
|
|
<>
|
|
<FileAudio className="h-4 w-4" />
|
|
<audio
|
|
controls
|
|
src={`data:${fileData.mimeType};base64,${fileData.data}`}
|
|
className="h-8"
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<File className="h-4 w-4" />
|
|
<span className="text-xs font-medium max-w-[160px] truncate">
|
|
{fileData.filename || 'attachment'}
|
|
</span>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={() => setFileData(null)}
|
|
className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-destructive-foreground opacity-100 group-hover:opacity-100 transition-opacity duration-150 shadow-md"
|
|
aria-label="Remove attachment"
|
|
>
|
|
<X className="h-2 w-2" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor area: scrollable, independent from footer */}
|
|
<div className="flex-auto overflow-y-auto relative">
|
|
{fontsReady ? (
|
|
<TextareaAutosize
|
|
ref={textareaRef}
|
|
value={text}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder="Ask Dexto anything... Type @ for resources, / for prompts, # for memories"
|
|
minRows={1}
|
|
maxRows={8}
|
|
className="w-full px-4 pt-4 pb-1 text-lg leading-7 placeholder:text-lg bg-transparent border-none resize-none outline-none ring-0 ring-offset-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none max-h-full"
|
|
/>
|
|
) : (
|
|
<textarea
|
|
ref={textareaRef}
|
|
rows={1}
|
|
value={text}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
placeholder="Ask Dexto anything... Type @ for resources, / for prompts, # for memories"
|
|
className="w-full px-4 pt-4 pb-1 text-lg leading-7 placeholder:text-lg bg-transparent border-none resize-none outline-none ring-0 ring-offset-0 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none"
|
|
/>
|
|
)}
|
|
|
|
{showMention &&
|
|
dropdownStyle &&
|
|
typeof window !== 'undefined' &&
|
|
ReactDOM.createPortal(
|
|
<div
|
|
style={dropdownStyle}
|
|
className="max-h-64 overflow-y-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
>
|
|
<ResourceAutocomplete
|
|
resources={resources}
|
|
query={mentionQuery}
|
|
selectedIndex={mentionIndex}
|
|
onHoverIndex={(i) => setMentionIndex(i)}
|
|
onSelect={(r) => applyMentionSelection(mentionIndex, r)}
|
|
loading={resourcesLoading}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{showMemoryHint &&
|
|
memoryHintStyle &&
|
|
typeof window !== 'undefined' &&
|
|
ReactDOM.createPortal(
|
|
<div
|
|
style={memoryHintStyle}
|
|
className="rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
>
|
|
<div className="p-2 flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Brain className="h-3.5 w-3.5" />
|
|
<span>
|
|
Press{' '}
|
|
<kbd className="px-1.5 py-0.5 text-xs bg-muted border border-border rounded">
|
|
Enter
|
|
</kbd>{' '}
|
|
to create a memory
|
|
</span>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
|
|
{/* Slash command autocomplete overlay (inside container to anchor positioning) */}
|
|
<SlashCommandAutocomplete
|
|
isVisible={showSlashCommands}
|
|
searchQuery={text}
|
|
onSelectPrompt={handlePromptSelect}
|
|
onClose={closeSlashCommands}
|
|
onCreatePrompt={openCreatePromptModal}
|
|
refreshKey={slashRefreshKey}
|
|
/>
|
|
|
|
{/* Footer row: normal flow */}
|
|
<ButtonFooter
|
|
leftButtons={
|
|
<div className="flex items-center gap-2">
|
|
<AttachButton
|
|
onImageAttach={triggerFileInput}
|
|
onPdfAttach={triggerPdfInput}
|
|
onAudioAttach={triggerAudioInput}
|
|
supports={{
|
|
// If not yet loaded (length===0), pass undefined so AttachButton defaults to enabled
|
|
image: supportedFileTypes.length
|
|
? supportedFileTypes.includes('image')
|
|
: undefined,
|
|
pdf: supportedFileTypes.length
|
|
? supportedFileTypes.includes('pdf')
|
|
: undefined,
|
|
audio: supportedFileTypes.length
|
|
? supportedFileTypes.includes('audio')
|
|
: undefined,
|
|
}}
|
|
useLargeBreakpoint={isSessionsPanelOpen}
|
|
/>
|
|
|
|
<RecordButton
|
|
isRecording={isRecording}
|
|
onToggleRecording={
|
|
isRecording ? stopRecording : startRecording
|
|
}
|
|
disabled={
|
|
supportedFileTypes.length > 0 &&
|
|
!supportedFileTypes.includes('audio')
|
|
}
|
|
useLargeBreakpoint={isSessionsPanelOpen}
|
|
/>
|
|
</div>
|
|
}
|
|
rightButtons={
|
|
<div className="flex items-center gap-2">
|
|
<ModelPickerModal />
|
|
|
|
{/* Stop/Cancel button shown when a run is in progress */}
|
|
<Button
|
|
type={processing ? 'button' : 'submit'}
|
|
onClick={
|
|
processing
|
|
? () => cancel(currentSessionId || undefined)
|
|
: undefined
|
|
}
|
|
disabled={
|
|
processing
|
|
? false
|
|
: (!text.trim() && !imageData && !fileData) ||
|
|
isSending
|
|
}
|
|
className={cn(
|
|
'h-10 w-10 p-0 rounded-full transition-all duration-200',
|
|
processing
|
|
? 'bg-secondary/80 text-secondary-foreground hover:bg-secondary shadow-sm hover:shadow-md border border-border/50'
|
|
: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-lg'
|
|
)}
|
|
aria-label={processing ? 'Stop' : 'Send message'}
|
|
title={processing ? 'Stop' : 'Send'}
|
|
>
|
|
{processing ? (
|
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
) : isSending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<SendHorizontal className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</ChatInputContainer>
|
|
</form>
|
|
|
|
{/* Previews moved inside bubble above editor */}
|
|
|
|
{/* Hidden inputs */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
id="image-upload"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageChange}
|
|
/>
|
|
<input
|
|
ref={pdfInputRef}
|
|
type="file"
|
|
id="pdf-upload"
|
|
accept="application/pdf"
|
|
className="hidden"
|
|
onChange={handlePdfChange}
|
|
/>
|
|
<input
|
|
ref={audioInputRef}
|
|
type="file"
|
|
id="audio-upload"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={handleAudioFileChange}
|
|
/>
|
|
|
|
<CreatePromptModal
|
|
open={showCreatePromptModal}
|
|
onClose={handleCloseCreatePrompt}
|
|
onCreated={handlePromptCreated}
|
|
/>
|
|
|
|
<CreateMemoryModal
|
|
open={showCreateMemoryModal}
|
|
onClose={() => setShowCreateMemoryModal(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|