diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 5a7bfbec..709c444f 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -13,6 +13,8 @@ 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'; @@ -115,6 +117,20 @@ 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); @@ -215,9 +231,19 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { - const text = consumePendingInputText(); - if (text) { - setMessage(text); + 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(); @@ -226,7 +252,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [pendingInputText, consumePendingInputText]); - const hasContent = message.trim() || attachedFiles.length > 0; + const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; @@ -239,7 +265,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; - const messageToQueue = message.replace(/^\n+|\n+$/g, ''); + 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, { @@ -256,7 +287,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo if (!isMobile) { textareaRef.current?.focus(); } - }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]); + }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -323,6 +354,23 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } } + 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); + } + } + if (!primaryText && additionalParts.length === 0) return; // Clear queue and input @@ -1525,6 +1573,22 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, 0); }} /> + {hasDrafts && ( +
+
+ Review comments: + + {draftCount} + +
+
+ )}
= ({ containerR const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); + const [isClosing, setIsClosing] = React.useState(false); const menuRef = React.useRef(null); const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null); + const hideTimeoutRef = React.useRef(null); const createSession = useSessionStore((state) => state.createSession); const setPendingInputText = useSessionStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); + React.useEffect(() => { + return () => { + if (hideTimeoutRef.current !== null) { + window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + }, []); + const hideMenu = React.useCallback(() => { - setPosition((prev) => ({ ...prev, show: false })); - setSelectedText(''); - pendingSelectionRef.current = null; + if (hideTimeoutRef.current !== null) { + window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + + setIsClosing(true); + hideTimeoutRef.current = window.setTimeout(() => { + setPosition((prev) => ({ ...prev, show: false })); + setSelectedText(''); + pendingSelectionRef.current = null; + setIsClosing(false); + hideTimeoutRef.current = null; + }, 140); }, []); const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; + if (hideTimeoutRef.current !== null) { + window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + setIsClosing(false); + const { text, rect } = pendingSelectionRef.current; // Position menu above the selection @@ -147,13 +174,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const handleAddToChat = React.useCallback(() => { if (!selectedText) return; - - // Append to current input - const currentPending = useSessionStore.getState().pendingInputText || ''; - const newText = currentPending - ? `${currentPending} ${selectedText}` - : selectedText; - setPendingInputText(newText); + + setPendingInputText(selectedText, 'append'); hideMenu(); @@ -166,7 +188,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const session = await createSession(undefined, null, null); if (session) { - setPendingInputText(selectedText); + setPendingInputText(selectedText, 'replace'); } hideMenu(); @@ -197,18 +219,18 @@ export const TextSelectionMenu: React.FC = ({ containerR 'fixed left-0 right-0 bottom-0 z-50', 'flex items-center justify-center gap-4', 'bg-[var(--surface-elevated)] border-t border-[var(--interactive-border)]', - 'px-4 py-3', + 'px-3 py-2', 'safe-area-bottom', - 'animate-in slide-in-from-bottom duration-200' + isClosing ? 'animate-out fade-out-0 duration-150 pointer-events-none' : 'animate-in fade-in-0 duration-150' )} style={{ - paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom, 0px))', + paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))', }} >