diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 029916aa..ad53420a 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -16,10 +16,15 @@ export interface AgentMentionAutocompleteHandle { handleKeyDown: (key: string) => void; } +type AutocompleteTab = 'commands' | 'agents' | 'files'; + interface AgentMentionAutocompleteProps { searchQuery: string; onAgentSelect: (agentName: string) => void; onClose: () => void; + showTabs?: boolean; + activeTab?: AutocompleteTab; + onTabSelect?: (tab: AutocompleteTab) => void; } const isMentionable = (mode?: string | null): boolean => { @@ -33,11 +38,15 @@ export const AgentMentionAutocomplete = React.forwardRef { const containerRef = React.useRef(null); const [selectedIndex, setSelectedIndex] = React.useState(0); const [agents, setAgents] = React.useState([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const ignoreTabClickRef = React.useRef(false); const { getVisibleAgents } = useConfigStore(); const { agents: agentsWithMetadata, loadAgents } = useAgentsStore(); @@ -177,7 +186,47 @@ export const AgentMentionAutocomplete = React.forwardRef - + {showTabs ? ( +
+
+ {([ + { id: 'commands' as const, label: 'Commands' }, + { id: 'agents' as const, label: 'Agents' }, + { id: 'files' as const, label: 'Files' }, + ]).map((tab) => ( + + ))} +
+
+ ) : null} + {agents.length ? (
{agents.map((agent, index) => renderAgent(agent, index))} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 6d319620..55345be7 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4,6 +4,7 @@ import { RiAddCircleLine, RiAiAgentLine, RiAttachment2, + RiCommandLine, RiFileUploadLine, RiSendPlane2Line, } from '@remixicon/react'; @@ -12,8 +13,6 @@ 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 } from '@/stores/useInlineCommentDraftStore'; -import { appendInlineComments } from '@/lib/messages/inlineComments'; import { AttachedFilesList } from './FileAttachment'; import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; @@ -62,6 +61,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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); @@ -114,20 +114,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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; - // Session activity for auto-send on idle const { phase: sessionPhase } = useCurrentSessionActivity(); const prevSessionPhaseRef = React.useRef(sessionPhase); @@ -239,7 +225,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [pendingInputText, consumePendingInputText]); - const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts; + const hasContent = message.trim() || attachedFiles.length > 0; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; @@ -249,16 +235,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; - // Get and consume drafts for this session - const sessionKey = currentSessionId; - const drafts = consumeDrafts(sessionKey); - - // Build message with appended drafts - let messageToQueue = message.replace(/^\n+|\n+$/g, ''); - if (drafts.length > 0) { - messageToQueue = appendInlineComments(messageToQueue, drafts); - } - + const messageToQueue = message.replace(/^\n+|\n+$/g, ''); const attachmentsToQueue = attachedFiles.map((file) => ({ ...file })); addToQueue(currentSessionId, { @@ -275,7 +252,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); + }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -300,7 +277,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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; @@ -342,28 +319,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } - // Get session key for drafts (use currentSessionId or 'draft' for new sessions) - const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); - let drafts: import('@/stores/useInlineCommentDraftStore').InlineCommentDraft[] = []; - if (sessionKey) { - drafts = consumeDrafts(sessionKey); - } - - // Append drafts to the message if any exist - if (drafts.length > 0) { - if (queuedMessages.length === 0) { - // No queue - append to primary text - primaryText = appendInlineComments(primaryText, drafts); - } else if (additionalParts.length > 0) { - // Has queue with additional parts - append to the last part (current input) - const lastPart = additionalParts[additionalParts.length - 1]; - lastPart.text = appendInlineComments(lastPart.text, drafts); - } else { - // Has queue but no additional parts yet (shouldn't happen with hasContent check, but handle it) - primaryText = appendInlineComments(primaryText, drafts); - } - } - if (!primaryText && additionalParts.length === 0) return; // Clear queue and input @@ -448,44 +403,44 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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(); + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error ?? ''); + const normalized = rawMessage.toLowerCase(); - console.error('Message send failed:', rawMessage || error); + 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; - } + 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 }); } - toast.error(rawMessage || 'Message failed to send. Attachments restored.'); - }); + 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(); @@ -510,7 +465,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo React.useEffect(() => { const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown'; const isNowIdle = sessionPhase === 'idle'; - + // Check if session was recently aborted (within last 2 seconds) const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => { const abortRecord = sessionAbortFlags.get(currentSessionId); @@ -518,12 +473,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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) { @@ -532,7 +487,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo autoSendTriggeredRef.current = false; }, 100); } - + prevSessionPhaseRef.current = sessionPhase; }, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]); @@ -582,15 +537,15 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // 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) @@ -699,6 +654,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (cursorPosition <= commandEnd && firstSpace === -1) { const commandText = value.substring(1, commandEnd); setCommandQuery(commandText); + setAutocompleteTab('commands'); setShowCommandAutocomplete(true); setShowFileMention(false); setShowAgentAutocomplete(false); @@ -720,6 +676,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (isWordBoundary && !hasSeparator) { setAgentQuery(textAfterHash); + setAutocompleteTab('agents'); setShowAgentAutocomplete(true); setShowFileMention(false); return; @@ -753,6 +710,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); + setAutocompleteTab('files'); setShowFileMention(true); } else { setShowFileMention(false); @@ -760,7 +718,86 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } else { setShowFileMention(false); } - }, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); + }, [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) { @@ -869,6 +906,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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); @@ -899,6 +951,22 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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); @@ -949,12 +1017,22 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setShowCommandAutocomplete(false); setCommandQuery(''); - setTimeout(() => { + const refocus = () => { if (textareaRef.current) { - textareaRef.current.focus(); + try { + textareaRef.current.focus({ preventScroll: true }); + } catch { + textareaRef.current.focus(); + } textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); } - }, 0); + }; + + requestAnimationFrame(() => { + refocus(); + requestAnimationFrame(refocus); + }); + setTimeout(refocus, 60); }; React.useEffect(() => { @@ -1135,7 +1213,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo 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-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]'); + 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'; @@ -1332,6 +1410,21 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const attachmentsControls = ( <> + {isMobile ? ( + + ) : null} {attachmentMenu} {settingsButton} @@ -1428,26 +1521,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, 0); }} /> - {/* Review comments chip */} - {hasDrafts && ( -
-
- Review comments: - - {draftCount} - -
-
- )}
= ({ onOpenSettings, scrollToBo backgroundColor: currentTheme?.colors?.surface?.subtle, }} > + {showCommandAutocomplete && ( setShowCommandAutocomplete(false)} /> )} - {} + { } {showAgentAutocomplete && ( setShowAgentAutocomplete(false)} - /> - )} + showTabs={isMobile} + activeTab={autocompleteTab} + onTabSelect={handleAutocompleteTabSelect} + onClose={() => setShowAgentAutocomplete(false)} + /> + )} - {showSkillAutocomplete && ( - setShowSkillAutocomplete(false)} - /> - )} + {showSkillAutocomplete && ( + setShowSkillAutocomplete(false)} + /> + )} - {showFileMention && ( + {showFileMention && ( setShowFileMention(false)} /> )} -