* feat(voice): add voice input/output support with multiple providers - Add BrowserVoiceButton component for Web Speech API voice input - Add VoiceProvider context for managing voice state across the app - Add TTS (Text-to-Speech) support with browser, macOS Say, and OpenAI providers - Add message TTS buttons to read assistant messages aloud - Add VoiceSettings page in OpenChamber settings - Add server endpoints for TTS and summarization services - Include slider component for voice rate/pitch/volume controls - Add hidden session support for background voice operations - Add Caddyfile for HTTPS support (required for microphone access) * fix: Build errors fixed and removed outdated ElevenLabs test code. * refactor(voice): use zen API with gpt-5-nano for TTS summarization Replace the hidden session + OpenCode SDK approach with direct calls to the opencode.ai zen API (same pattern used for commit message and PR description generation). - Rewrite summarization-service.js to call zen/v1/responses with gpt-5-nano - Remove hidden session logic (hiddenSession.ts, sessionStore filtering) - Remove summarizeModel setting and model selector from VoiceSettings - Simplify client-side summarize.ts to no longer pass model params - Clean up callers in useMessageTTS and useBrowserVoice * fix(voice): remove false 'voice not supported' warning in settings Mobile Safari does support voice but the isSupported check was incorrectly flagging it. Remove the warning banner entirely. * feat(voice): add configurable summary length limit for TTS output Add a slider (50-2000 chars) in voice settings to control max summary length. The limit is passed through the summarize endpoint and speak endpoint to the zen API prompt, with token budget scaled accordingly. * fix(voice): add diagnostic logging and sanitize TTS fallback Add console logging throughout the summarization flow (client + server) to trace why text may not be summarized. Fix silent error swallowing in /api/tts/speak. Always apply sanitizeForTTS even when summarization is disabled so raw markdown/code is never spoken verbatim. * fix(voice): fix token budget starving model of output tokens max_output_tokens includes both reasoning and output tokens. With effort:'low', reasoning alone consumes ~128 tokens, so a budget of 100 left zero tokens for the actual summary text. Use a fixed 1000 token budget (matching commit message generation) and control output length via the prompt's character limit instruction instead. * chore(voice): remove diagnostic logging from summarization flow * fix(voice): don't request mic permission on mobile page load Remove the useEffect that pre-requested microphone permission when the BrowserVoiceButton component mounted on mobile. This caused an unwanted permission prompt immediately on page load before the user tapped the mic icon. Permission is now only requested on explicit user interaction. * fix(voice): remove unused BrowserVoiceButton binding * fix(voice): desktop mic flow + non-continuous draft mode * fix(voice): stabilize continuous loop and polish controls * feat(settings): mark voice section experimental --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
1915 lines
77 KiB
TypeScript
1915 lines
77 KiB
TypeScript
import React from 'react';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
RiAddCircleLine,
|
|
RiAiAgentLine,
|
|
RiAttachment2,
|
|
RiCommandLine,
|
|
RiFileUploadLine,
|
|
RiSendPlane2Line,
|
|
} from '@remixicon/react';
|
|
import { BrowserVoiceButton } from '@/components/voice';
|
|
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 } from '@/stores/types/sessionTypes';
|
|
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
|
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 { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
|
import { StatusRow } from './StatusRow';
|
|
import { MobileAgentButton } from './MobileAgentButton';
|
|
import { MobileModelButton } from './MobileModelButton';
|
|
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|
import { toast } from '@/components/ui';
|
|
import { useFileStore } from '@/stores/fileStore';
|
|
import { useMessageStore } from '@/stores/messageStore';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isIMECompositionEvent } from '@/lib/ime';
|
|
import { StopIcon } from '@/components/icons/StopIcon';
|
|
import type { MobileControlsPanel } from './mobileControlsUtils';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
|
|
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
|
|
|
interface ChatInputProps {
|
|
onOpenSettings?: () => void;
|
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void;
|
|
}
|
|
|
|
const CHAT_INPUT_DRAFT_KEY = 'openchamber_chat_input_draft';
|
|
|
|
// Helper to safely read from localStorage
|
|
const getStoredDraft = (): string => {
|
|
try {
|
|
return localStorage.getItem(CHAT_INPUT_DRAFT_KEY) ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
|
// Track if we restored a draft on mount (for text selection)
|
|
const initialDraftRef = React.useRef<string | null>(null);
|
|
const [message, setMessage] = React.useState(() => {
|
|
const draft = getStoredDraft();
|
|
if (draft) {
|
|
initialDraftRef.current = draft;
|
|
}
|
|
return draft;
|
|
});
|
|
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 [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
|
|
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
|
const [skillQuery, setSkillQuery] = React.useState('');
|
|
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
|
const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
|
|
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
|
// Message history navigation state (up/down arrow to recall previous messages)
|
|
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
|
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
|
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 consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
|
|
|
|
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
|
const agents = getVisibleAgents();
|
|
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
|
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft } = useUIStore();
|
|
const { working } = useAssistantStatus();
|
|
const { currentTheme } = useThemeSystem();
|
|
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);
|
|
|
|
// Inline comment drafts
|
|
const draftCount = useInlineCommentDraftStore(
|
|
React.useCallback(
|
|
(state) => {
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
|
if (!sessionKey) return 0;
|
|
return (state.drafts[sessionKey] ?? []).length;
|
|
},
|
|
[currentSessionId, newSessionDraftOpen]
|
|
)
|
|
);
|
|
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
|
const hasDrafts = draftCount > 0;
|
|
|
|
// User message history for up/down arrow navigation
|
|
// Get raw messages from store (stable reference)
|
|
const sessionMessages = useMessageStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined),
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
// Derive user message history with useMemo to avoid infinite re-renders
|
|
const userMessageHistory = React.useMemo(() => {
|
|
if (!sessionMessages) return [];
|
|
return sessionMessages
|
|
.filter((m) => m.info.role === 'user')
|
|
.map((m) => {
|
|
const textPart = m.parts.find((p) => p.type === 'text');
|
|
if (textPart && 'text' in textPart) {
|
|
return String(textPart.text);
|
|
}
|
|
return '';
|
|
})
|
|
.filter((text) => text.length > 0)
|
|
.reverse(); // Most recent first
|
|
}, [sessionMessages]);
|
|
|
|
// Handle initial draft restoration and text selection
|
|
const hasHandledInitialDraftRef = React.useRef(false);
|
|
React.useEffect(() => {
|
|
if (hasHandledInitialDraftRef.current) return;
|
|
hasHandledInitialDraftRef.current = true;
|
|
|
|
const draft = initialDraftRef.current;
|
|
if (!draft) return;
|
|
|
|
if (!persistChatDraft) {
|
|
// Setting disabled - clear the restored draft
|
|
setMessage('');
|
|
try {
|
|
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
} else {
|
|
// Setting enabled - select all text
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.select();
|
|
});
|
|
}
|
|
}, [persistChatDraft]);
|
|
|
|
// Handle session switching: clear draft if persist disabled, select if enabled
|
|
const prevSessionIdRef = React.useRef(currentSessionId);
|
|
React.useEffect(() => {
|
|
if (prevSessionIdRef.current !== currentSessionId) {
|
|
prevSessionIdRef.current = currentSessionId;
|
|
|
|
if (!persistChatDraft) {
|
|
// Clear draft when switching sessions if persist is disabled
|
|
setMessage('');
|
|
} else if (message) {
|
|
// Select text if there's any draft when switching sessions
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.select();
|
|
});
|
|
}
|
|
}
|
|
}, [currentSessionId, persistChatDraft, message]);
|
|
|
|
// Persist chat input draft to localStorage (only if setting enabled)
|
|
React.useEffect(() => {
|
|
if (!persistChatDraft) {
|
|
// Clear stored draft when setting is disabled
|
|
try {
|
|
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
if (message) {
|
|
localStorage.setItem(CHAT_INPUT_DRAFT_KEY, message);
|
|
} else {
|
|
localStorage.removeItem(CHAT_INPUT_DRAFT_KEY);
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}, [message, persistChatDraft]);
|
|
|
|
// 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]);
|
|
|
|
const handleOpenMobileControls = React.useCallback(() => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
|
|
if (mobileControlsOpen) {
|
|
setMobileControlsOpen(false);
|
|
return;
|
|
}
|
|
|
|
setMobileControlsPanel(null);
|
|
|
|
if (isKeyboardOpen) {
|
|
textareaRef.current?.blur();
|
|
requestAnimationFrame(() => {
|
|
setMobileControlsOpen(true);
|
|
});
|
|
return;
|
|
}
|
|
|
|
setMobileControlsOpen(true);
|
|
}, [isMobile, isKeyboardOpen, mobileControlsOpen]);
|
|
|
|
const handleCloseMobileControls = React.useCallback(() => {
|
|
setMobileControlsOpen(false);
|
|
}, []);
|
|
|
|
const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
setMobileControlsOpen(false);
|
|
textareaRef.current?.blur();
|
|
requestAnimationFrame(() => {
|
|
setMobileControlsPanel(panel);
|
|
});
|
|
}, [isMobile]);
|
|
|
|
const handleReturnToUnifiedControls = React.useCallback(() => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
setMobileControlsPanel(null);
|
|
requestAnimationFrame(() => {
|
|
setMobileControlsOpen(true);
|
|
});
|
|
}, [isMobile]);
|
|
|
|
// Consume pending input text (e.g., from revert action)
|
|
React.useEffect(() => {
|
|
if (pendingInputText !== null) {
|
|
const pending = consumePendingInputText();
|
|
if (pending?.text) {
|
|
if (pending.mode === 'append') {
|
|
setMessage((prev) => {
|
|
const next = pending.text.trim();
|
|
if (!next) return prev;
|
|
const base = prev.trimEnd();
|
|
if (!base.trim()) return next;
|
|
return `${base} ${next}`;
|
|
});
|
|
} else {
|
|
setMessage(pending.text);
|
|
}
|
|
// Focus textarea after setting message
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
}, [pendingInputText, consumePendingInputText]);
|
|
|
|
const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts;
|
|
const hasQueuedMessages = queuedMessages.length > 0;
|
|
const canSend = hasContent || hasQueuedMessages;
|
|
|
|
const canAbort = working.isWorking;
|
|
|
|
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
|
const handleSubmitRef = React.useRef<(e?: React.FormEvent) => Promise<void>>(async () => {});
|
|
|
|
// Add message to queue instead of sending
|
|
const handleQueueMessage = React.useCallback(() => {
|
|
if (!hasContent || !currentSessionId) return;
|
|
|
|
const drafts = consumeDrafts(currentSessionId);
|
|
|
|
let messageToQueue = message.replace(/^\n+|\n+$/g, '');
|
|
if (drafts.length > 0) {
|
|
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
|
}
|
|
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, consumeDrafts]);
|
|
|
|
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[]; synthetic?: boolean }> = [];
|
|
|
|
// Consume any pending synthetic parts (from conflict resolution, etc.)
|
|
const syntheticParts = consumePendingSyntheticParts();
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
let drafts: InlineCommentDraft[] = [];
|
|
if (sessionKey) {
|
|
drafts = consumeDrafts(sessionKey);
|
|
}
|
|
|
|
if (drafts.length > 0) {
|
|
if (queuedMessages.length === 0) {
|
|
primaryText = appendInlineComments(primaryText, drafts);
|
|
} else if (additionalParts.length > 0) {
|
|
const lastPart = additionalParts[additionalParts.length - 1];
|
|
lastPart.text = appendInlineComments(lastPart.text, drafts);
|
|
} else {
|
|
primaryText = appendInlineComments(primaryText, drafts);
|
|
}
|
|
}
|
|
|
|
// Add synthetic parts (from conflict resolution, etc.)
|
|
if (syntheticParts && syntheticParts.length > 0) {
|
|
for (const part of syntheticParts) {
|
|
additionalParts.push({
|
|
text: part.text,
|
|
synthetic: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!primaryText && additionalParts.length === 0) return;
|
|
|
|
// Clear queue and input
|
|
if (currentSessionId && hasQueuedMessages) {
|
|
clearQueue(currentSessionId);
|
|
}
|
|
setMessage('');
|
|
// Reset message history navigation state
|
|
setHistoryIndex(-1);
|
|
setDraftMessage('');
|
|
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();
|
|
}
|
|
};
|
|
|
|
handleSubmitRef.current = handleSubmit;
|
|
|
|
// Primary action for send button - respects queue mode setting
|
|
const handlePrimaryAction = React.useCallback(() => {
|
|
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
|
if (queueModeEnabled && canQueue) {
|
|
handleQueueMessage();
|
|
} else {
|
|
void handleSubmitRef.current();
|
|
}
|
|
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
|
|
|
|
// Auto-send queued messages when session becomes idle (but not after abort)
|
|
React.useEffect(() => {
|
|
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'retry';
|
|
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();
|
|
handleCycleAgent();
|
|
return;
|
|
}
|
|
|
|
// Handle ArrowUp/ArrowDown for message history navigation
|
|
// ArrowUp: only when cursor at start (position 0) or input is empty
|
|
// ArrowDown: also works when cursor at end (to cycle forward through history)
|
|
const isAnyAutocompleteOpen = showCommandAutocomplete || showAgentAutocomplete || showSkillAutocomplete || showFileMention;
|
|
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
|
|
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
|
|
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
|
|
const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd);
|
|
|
|
if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) {
|
|
e.preventDefault();
|
|
if (historyIndex === -1) {
|
|
// Entering history mode - save current input as draft
|
|
setDraftMessage(message);
|
|
setHistoryIndex(0);
|
|
setMessage(userMessageHistory[0]);
|
|
} else if (historyIndex < userMessageHistory.length - 1) {
|
|
// Navigate to older message
|
|
const newIndex = historyIndex + 1;
|
|
setHistoryIndex(newIndex);
|
|
setMessage(userMessageHistory[newIndex]);
|
|
}
|
|
// Move cursor to start after history navigation
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.setSelectionRange(0, 0);
|
|
});
|
|
// If at oldest message, do nothing
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) {
|
|
e.preventDefault();
|
|
if (historyIndex === 0) {
|
|
// Exit history mode - restore draft
|
|
setHistoryIndex(-1);
|
|
setMessage(draftMessage);
|
|
setDraftMessage('');
|
|
} else {
|
|
// Navigate to newer message
|
|
const newIndex = historyIndex - 1;
|
|
setHistoryIndex(newIndex);
|
|
setMessage(userMessageHistory[newIndex]);
|
|
}
|
|
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 handleCycleAgent = React.useCallback(() => {
|
|
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);
|
|
}
|
|
}, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
|
|
|
|
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);
|
|
setAutocompleteTab('commands');
|
|
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);
|
|
setAutocompleteTab('agents');
|
|
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);
|
|
setAutocompleteTab('files');
|
|
setShowFileMention(true);
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
} else {
|
|
setShowFileMention(false);
|
|
}
|
|
}, [setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
|
|
|
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '#' | '@') => {
|
|
const nextMessage = message.length === 0
|
|
? prefix
|
|
: (message[0] === '/' || message[0] === '#' || message[0] === '@')
|
|
? `${prefix}${message.slice(1)}`
|
|
: `${prefix}${message}`;
|
|
setMessage(nextMessage);
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
const nextCursor = Math.min(nextMessage.length, textareaRef.current.value.length);
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(nextMessage, nextMessage.length);
|
|
});
|
|
}, [adjustTextareaHeight, message, setMessage, updateAutocompleteState]);
|
|
|
|
const handleAutocompleteTabSelect = React.useCallback((tab: 'commands' | 'agents' | 'files') => {
|
|
const textarea = textareaRef.current;
|
|
if (isMobile && textarea) {
|
|
try {
|
|
textarea.focus({ preventScroll: true });
|
|
} catch {
|
|
textarea.focus();
|
|
}
|
|
const len = textarea.value.length;
|
|
try {
|
|
textarea.setSelectionRange(len, len);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
setAutocompleteTab(tab);
|
|
setCommandQuery('');
|
|
setAgentQuery('');
|
|
setMentionQuery('');
|
|
if (tab === 'commands') {
|
|
applyAutocompletePrefix('/');
|
|
}
|
|
if (tab === 'agents') {
|
|
applyAutocompletePrefix('#');
|
|
}
|
|
if (tab === 'files') {
|
|
applyAutocompletePrefix('@');
|
|
}
|
|
setShowSkillAutocomplete(false);
|
|
setShowCommandAutocomplete(tab === 'commands');
|
|
setShowAgentAutocomplete(tab === 'agents');
|
|
setShowFileMention(tab === 'files');
|
|
}, [applyAutocompletePrefix, isMobile, setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
|
|
|
const handleOpenCommandMenu = React.useCallback(() => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
try {
|
|
textarea.focus({ preventScroll: true });
|
|
} catch {
|
|
textarea.focus();
|
|
}
|
|
const len = textarea.value.length;
|
|
try {
|
|
textarea.setSelectionRange(len, len);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}
|
|
applyAutocompletePrefix('/');
|
|
setCommandQuery('');
|
|
setAutocompleteTab('commands');
|
|
setShowCommandAutocomplete(true);
|
|
setShowAgentAutocomplete(false);
|
|
setShowFileMention(false);
|
|
setShowSkillAutocomplete(false);
|
|
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
|
|
|
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);
|
|
} else if (textareaRef.current) {
|
|
const newMessage =
|
|
message.substring(0, cursorPosition) +
|
|
`@${file.name} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
const nextCursor = cursorPosition + file.name.length + 2;
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = nextCursor;
|
|
textareaRef.current.selectionEnd = nextCursor;
|
|
}
|
|
adjustTextareaHeight();
|
|
updateAutocompleteState(newMessage, nextCursor);
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
} else if (textareaRef.current) {
|
|
const newMessage =
|
|
message.substring(0, cursorPosition) +
|
|
`#${agentName} ` +
|
|
message.substring(cursorPosition);
|
|
setMessage(newMessage);
|
|
|
|
const nextCursor = cursorPosition + 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('');
|
|
|
|
const refocus = () => {
|
|
if (textareaRef.current) {
|
|
try {
|
|
textareaRef.current.focus({ preventScroll: true });
|
|
} catch {
|
|
textareaRef.current.focus();
|
|
}
|
|
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(() => {
|
|
refocus();
|
|
requestAnimationFrame(refocus);
|
|
});
|
|
setTimeout(refocus, 60);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (currentSessionId && textareaRef.current && !isMobile) {
|
|
textareaRef.current.focus();
|
|
}
|
|
}, [currentSessionId, isMobile]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileControlsOpen(false);
|
|
setMobileControlsPanel(null);
|
|
}
|
|
}, [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 = '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 buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
|
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
|
|
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
|
|
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
|
|
|
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
|
|
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
|
|
|
// Send button - respects queue mode setting
|
|
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();
|
|
handlePrimaryAction();
|
|
}}
|
|
onClick={(event) => {
|
|
if (!isMobile) {
|
|
return;
|
|
}
|
|
|
|
if (sendTriggeredByPointerDownRef.current) {
|
|
sendTriggeredByPointerDownRef.current = false;
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
handlePrimaryAction();
|
|
}}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
canSend && (currentSessionId || newSessionDraftOpen)
|
|
? 'text-primary hover:text-primary'
|
|
: 'opacity-30'
|
|
)}
|
|
aria-label="Send message"
|
|
>
|
|
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
|
|
</button>
|
|
);
|
|
|
|
// Queue button for adding message to queue while working
|
|
const queueButton = (
|
|
<button
|
|
type="button"
|
|
disabled={!hasContent || !currentSessionId}
|
|
onPointerDownCapture={(event) => {
|
|
if (!isMobile || event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
|
|
if (!hasContent || !currentSessionId) {
|
|
return;
|
|
}
|
|
|
|
sendTriggeredByPointerDownRef.current = true;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleQueueMessage();
|
|
}}
|
|
onClick={(event) => {
|
|
if (isMobile) {
|
|
if (sendTriggeredByPointerDownRef.current) {
|
|
sendTriggeredByPointerDownRef.current = false;
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
}
|
|
handleQueueMessage();
|
|
}}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'absolute bottom-full left-1/2 -translate-x-1/2 mb-1',
|
|
hasContent && currentSessionId
|
|
? 'text-primary hover:text-primary'
|
|
: 'opacity-30'
|
|
)}
|
|
aria-label="Queue message"
|
|
>
|
|
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
|
|
</button>
|
|
);
|
|
|
|
// Stop button replaces send button when working
|
|
const stopButton = (
|
|
<button
|
|
type="button"
|
|
onClick={handleAbort}
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'text-[var(--status-error)] hover:text-[var(--status-error)]'
|
|
)}
|
|
aria-label="Stop generating"
|
|
>
|
|
<StopIcon className={cn(stopIconSizeClass)} />
|
|
</button>
|
|
);
|
|
|
|
// Action buttons area: either send button, or stop (+ optional queue button floating above)
|
|
const actionButtons = canAbort ? (
|
|
<div className="relative">
|
|
{hasContent && queueButton}
|
|
{stopButton}
|
|
</div>
|
|
) : (
|
|
sendButton
|
|
);
|
|
|
|
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={footerIconButtonClass}
|
|
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={footerIconButtonClass}
|
|
title='Model and agent settings'
|
|
aria-label='Model and agent settings'
|
|
>
|
|
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
|
</button>
|
|
) : null;
|
|
|
|
const attachmentsControls = (
|
|
<div className="flex items-center gap-x-1.5">
|
|
{isMobile ? (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
footerIconButtonClass,
|
|
'rounded-md text-muted-foreground',
|
|
'hover:bg-interactive-hover/40 hover:text-foreground'
|
|
)}
|
|
onPointerDownCapture={(event) => {
|
|
if (event.pointerType === 'touch') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
}}
|
|
onClick={handleOpenCommandMenu}
|
|
title="Commands"
|
|
aria-label="Commands"
|
|
>
|
|
<RiCommandLine className={cn(iconSizeClass)} />
|
|
</button>
|
|
) : null}
|
|
{attachmentMenu}
|
|
{settingsButton}
|
|
</div>
|
|
);
|
|
|
|
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={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
|
className={cn(
|
|
"relative pt-0 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}
|
|
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);
|
|
}}
|
|
/>
|
|
{hasDrafts && (
|
|
<div className="pb-2">
|
|
<div
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
|
|
style={{
|
|
backgroundColor: currentTheme?.colors?.surface?.elevated,
|
|
borderColor: currentTheme?.colors?.interactive?.border,
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium text-muted-foreground">Review comments:</span>
|
|
<span className="text-xs font-semibold" style={{ color: currentTheme?.colors?.status?.info }}>
|
|
{draftCount}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col relative overflow-visible",
|
|
"border border-border/80",
|
|
"focus-within:ring-1 focus-within:ring-primary/50"
|
|
)}
|
|
style={{
|
|
borderRadius: cornerRadius,
|
|
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
|
}}
|
|
>
|
|
|
|
{showCommandAutocomplete && (
|
|
<CommandAutocomplete
|
|
ref={commandRef}
|
|
searchQuery={commandQuery}
|
|
onCommandSelect={handleCommandSelect}
|
|
showTabs={isMobile}
|
|
activeTab={autocompleteTab}
|
|
onTabSelect={handleAutocompleteTabSelect}
|
|
onClose={() => setShowCommandAutocomplete(false)}
|
|
/>
|
|
)}
|
|
{ }
|
|
{showAgentAutocomplete && (
|
|
<AgentMentionAutocomplete
|
|
ref={agentRef}
|
|
searchQuery={agentQuery}
|
|
onAgentSelect={handleAgentSelect}
|
|
showTabs={isMobile}
|
|
activeTab={autocompleteTab}
|
|
onTabSelect={handleAutocompleteTabSelect}
|
|
onClose={() => setShowAgentAutocomplete(false)}
|
|
/>
|
|
)}
|
|
|
|
{showSkillAutocomplete && (
|
|
<SkillAutocomplete
|
|
ref={skillRef}
|
|
searchQuery={skillQuery}
|
|
onSkillSelect={handleSkillSelect}
|
|
onClose={() => setShowSkillAutocomplete(false)}
|
|
/>
|
|
)}
|
|
|
|
{showFileMention && (
|
|
|
|
<FileMentionAutocomplete
|
|
ref={mentionRef}
|
|
searchQuery={mentionQuery}
|
|
onFileSelect={handleFileSelect}
|
|
showTabs={isMobile}
|
|
activeTab={autocompleteTab}
|
|
onTabSelect={handleAutocompleteTabSelect}
|
|
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}
|
|
autoCorrect={isMobile ? "on" : "off"}
|
|
autoCapitalize={isMobile ? "sentences" : "off"}
|
|
spellCheck={isMobile}
|
|
outerClassName="focus-within:ring-0"
|
|
className={cn(
|
|
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
|
isMobile ? "py-2.5" : "pt-4 pb-2"
|
|
)}
|
|
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,
|
|
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
|
)}
|
|
style={{
|
|
borderBottomLeftRadius: cornerRadius,
|
|
borderBottomRightRadius: cornerRadius,
|
|
}}
|
|
data-chat-input-footer="true"
|
|
>
|
|
{isMobile ? (
|
|
<>
|
|
<div className="flex w-full items-center justify-between gap-x-1.5">
|
|
<div className="flex items-center gap-x-1">
|
|
{attachmentsControls}
|
|
</div>
|
|
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
|
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
|
<MobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
|
|
<MobileAgentButton onCycleAgent={handleCycleAgent} onOpenAgentPanel={() => setMobileControlsPanel('agent')} className="min-w-0 flex-shrink" />
|
|
</div>
|
|
<div className="flex items-center gap-x-1 flex-shrink-0">
|
|
<BrowserVoiceButton />
|
|
{actionButtons}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ModelControls
|
|
className="hidden"
|
|
mobilePanel={mobileControlsPanel}
|
|
onMobilePanelChange={setMobileControlsPanel}
|
|
onMobilePanelSelection={handleReturnToUnifiedControls}
|
|
onAgentPanelSelection={() => setMobileControlsPanel(null)}
|
|
/>
|
|
<UnifiedControlsDrawer
|
|
open={mobileControlsOpen}
|
|
onClose={handleCloseMobileControls}
|
|
onOpenModel={() => handleOpenMobilePanel('model')}
|
|
onOpenEffort={() => handleOpenMobilePanel('variant')}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
|
{attachmentsControls}
|
|
</div>
|
|
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
|
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
|
<BrowserVoiceButton />
|
|
{actionButtons}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile Session Status Bar - 在输入框上方 */}
|
|
{isMobile && <MobileSessionStatusBar />}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|