'use client'; import { useRef, useCallback, useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; import { IconAlertTriangle, IconPlus, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; import { TaskCreateDialog } from '@/components/task-create-dialog'; import { TipTapInput, type TipTapInputHandle } from './tiptap-input'; import { ClarificationInputOverlay } from './clarification-input-overlay'; import { ChatInputFocusHint } from './chat-input-focus-hint'; import { ResizeHandle } from './resize-handle'; import { TodoSummary } from './todo-summary'; import { QueuedMessageIndicator, type QueuedMessageIndicatorHandle } from './queued-message-indicator'; import { ChatInputToolbar } from './chat-input-toolbar'; import { ContextZone } from './context-items/context-zone'; import type { ContextItem, ImageContextItem } from '@/lib/types/context'; import type { ContextFile } from '@/lib/state/context-files-store'; import { useResizableInput } from '@/hooks/use-resizable-input'; import { processImageFile, formatBytes, MAX_IMAGES, MAX_TOTAL_SIZE, type ImageAttachment, } from './image-attachment-preview'; import { getChatDraftText, setChatDraftText, getChatDraftAttachments, setChatDraftAttachments, setChatDraftContent, restoreAttachmentPreview, } from '@/lib/local-storage'; import type { Message } from '@/lib/types/http'; import type { DiffComment } from '@/lib/diff/types'; // Re-export ImageAttachment type for consumers export type { ImageAttachment } from './image-attachment-preview'; // Type for message attachments sent to backend export type MessageAttachment = { type: 'image'; data: string; // Base64 data mime_type: string; // MIME type }; export type ChatInputContainerHandle = { focusInput: () => void; getTextareaElement: () => HTMLElement | null; getValue: () => string; getSelectionStart: () => number; insertText: (text: string, from: number, to: number) => void; }; type TodoItem = { text: string; done?: boolean; }; type ChatInputContainerProps = { onSubmit: (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: ContextFile[]) => void; sessionId: string | null; taskId: string | null; taskTitle?: string; taskDescription: string; planModeEnabled: boolean; onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; isStarting: boolean; isSending: boolean; onCancel: () => void; placeholder?: string; pendingClarification?: Message | null; onClarificationResolved?: () => void; showRequestChangesTooltip?: boolean; onRequestChangesTooltipDismiss?: () => void; /** Pending review comments grouped by file — kept for submit collection */ pendingCommentsByFile?: Record; /** Chat submit key preference */ submitKey?: 'enter' | 'cmd_enter'; /** Whether the current agent has commands (affects placeholder) */ hasAgentCommands?: boolean; /** Whether the session is in a terminal state (FAILED/CANCELLED) */ isFailed?: boolean; /** Whether a message is queued */ isQueued?: boolean; /** Context items to display as chips above input */ contextItems?: ContextItem[]; /** Whether plan is selected as context in the popover */ planContextEnabled?: boolean; /** Context files for popover */ contextFiles?: ContextFile[]; /** Toggle a context file in/out */ onToggleContextFile?: (file: ContextFile) => void; /** Add a context file (for @mention) */ onAddContextFile?: (file: ContextFile) => void; /** Todo items from processed messages */ todoItems?: TodoItem[]; /** Queued message content */ queuedMessage?: string | null; /** Cancel queued message */ onCancelQueue?: () => void; /** Update queued message content */ updateQueueContent?: (content: string) => Promise; /** Ref for queued message indicator */ queuedMessageRef?: React.RefObject; /** Callback when queue edit completes */ onQueueEditComplete?: () => void; /** Whether the dockview group containing this panel is focused */ isPanelFocused?: boolean; }; export const ChatInputContainer = forwardRef( function ChatInputContainer( { onSubmit, sessionId, taskId, taskTitle, taskDescription, planModeEnabled, onPlanModeChange, isAgentBusy, isStarting, isSending, onCancel, placeholder, pendingClarification, onClarificationResolved, showRequestChangesTooltip = false, onRequestChangesTooltipDismiss, pendingCommentsByFile, submitKey = 'cmd_enter', hasAgentCommands = false, isFailed = false, isQueued = false, contextItems = [], planContextEnabled = false, contextFiles = [], onToggleContextFile, onAddContextFile, todoItems = [], queuedMessage, onCancelQueue, updateQueueContent, queuedMessageRef, onQueueEditComplete, isPanelFocused, }, ref ) { // Keep input state local to avoid re-rendering parent on every keystroke const [value, setValue] = useState(() => sessionId ? getChatDraftText(sessionId) : '' ); const [isInputFocused, setIsInputFocused] = useState(false); const [attachments, setAttachments] = useState(() => sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : [] ); const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); const [contextPopoverOpen, setContextPopoverOpen] = useState(false); const inputRef = useRef(null); // Message history navigation state const [historyIndex, setHistoryIndex] = useState(-1); // Hydrate draft when switching sessions (component stays mounted) const prevSessionIdRef = useRef(sessionId); useEffect(() => { if (sessionId === prevSessionIdRef.current) return; prevSessionIdRef.current = sessionId; /* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */ if (sessionId) { setValue(getChatDraftText(sessionId)); setAttachments(getChatDraftAttachments(sessionId).map(restoreAttachmentPreview)); } else { setValue(''); setAttachments([]); } /* eslint-enable react-hooks/set-state-in-effect */ }, [sessionId]); // Resizable input const { height, resetHeight, containerRef, resizeHandleProps } = useResizableInput(sessionId ?? undefined); // Expose imperative handle for parent useImperativeHandle( ref, () => ({ focusInput: () => inputRef.current?.focus(), getTextareaElement: () => inputRef.current?.getTextareaElement() ?? null, getValue: () => inputRef.current?.getValue() ?? '', getSelectionStart: () => inputRef.current?.getSelectionStart() ?? 0, insertText: (text: string, from: number, to: number) => { inputRef.current?.insertText(text, from, to); }, }), [] ); // Focus input when request changes tooltip is shown useEffect(() => { if (showRequestChangesTooltip && inputRef.current) { inputRef.current.focus(); } }, [showRequestChangesTooltip]); // Use ref for value to keep handleSubmit stable const valueRef = useRef(value); useEffect(() => { valueRef.current = value; }, [value]); // Ref for attachments to keep handleSubmit stable const attachmentsRef = useRef(attachments); useEffect(() => { attachmentsRef.current = attachments; // Persist attachments when they change (skip during session switch — handled by hydrate effect) if (sessionId && prevSessionIdRef.current === sessionId) { setChatDraftAttachments(sessionId, attachments); } }, [attachments, sessionId]); // Handle image paste (called by TipTapInput's handlePaste editorProp) const handleImagePaste = useCallback(async (files: File[]) => { if (attachments.length >= MAX_IMAGES) { console.warn(`Maximum ${MAX_IMAGES} images allowed`); return; } const currentTotalSize = attachments.reduce((sum, att) => sum + att.size, 0); for (const file of files) { if (attachments.length >= MAX_IMAGES) break; if (currentTotalSize + file.size > MAX_TOTAL_SIZE) { console.warn('Total attachment size limit exceeded'); break; } const attachment = await processImageFile(file); if (attachment) { setAttachments(prev => [...prev, attachment]); } } }, [attachments]); // Remove attachment handler const handleRemoveAttachment = useCallback((id: string) => { setAttachments(prev => prev.filter(att => att.id !== id)); }, []); // Handler for agent slash commands const handleAgentCommand = useCallback( (commandName: string) => { onSubmit(`/${commandName}`); }, [onSubmit] ); // Ref for pending comments const pendingCommentsRef = useRef(pendingCommentsByFile); useEffect(() => { pendingCommentsRef.current = pendingCommentsByFile; }, [pendingCommentsByFile]); // Change handler const handleChange = useCallback( (newValue: string) => { setValue(newValue); if (sessionId) setChatDraftText(sessionId, newValue); if (historyIndex >= 0) { setHistoryIndex(-1); } if (showRequestChangesTooltip && onRequestChangesTooltipDismiss) { onRequestChangesTooltipDismiss(); } }, [showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex, sessionId] ); // Stable submit handler using refs const handleSubmit = useCallback(() => { if (isSending) return; const trimmed = valueRef.current.trim(); const comments = pendingCommentsRef.current; const currentAttachments = attachmentsRef.current; const allComments: DiffComment[] = []; if (comments) { for (const filePath of Object.keys(comments)) { allComments.push(...comments[filePath]); } } if (!trimmed && allComments.length === 0 && currentAttachments.length === 0) return; const messageAttachments: MessageAttachment[] = currentAttachments.map(att => ({ type: 'image' as const, data: att.data, mime_type: att.mimeType, })); // Extract inline @mentions from the editor before clearing const inlineMentions = inputRef.current?.getMentions() ?? []; onSubmit( trimmed, allComments.length > 0 ? allComments : undefined, messageAttachments.length > 0 ? messageAttachments : undefined, inlineMentions.length > 0 ? inlineMentions : undefined ); // Clear editor imperatively (suppresses stale onUpdate) inputRef.current?.clear(); setValue(''); setAttachments([]); setHistoryIndex(-1); resetHeight(); // Clear persisted draft after sending if (sessionId) { setChatDraftText(sessionId, ''); setChatDraftContent(sessionId, null); setChatDraftAttachments(sessionId, []); } }, [onSubmit, isSending, sessionId, resetHeight]); // Merge images into context items const allItems = useMemo((): ContextItem[] => { const imageItems: ImageContextItem[] = attachments.map(att => ({ kind: 'image' as const, id: `image:${att.id}`, label: `Image (${formatBytes(att.size)})`, attachment: att, onRemove: () => handleRemoveAttachment(att.id), })); return [...contextItems, ...imageItems]; }, [contextItems, attachments, handleRemoveAttachment]); // Derived state const isDisabled = isStarting || isSending || isFailed; const hasPendingClarification = pendingClarification && onClarificationResolved; const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0; const inputPlaceholder = placeholder || (isAgentBusy ? 'Queue more instructions...' : hasAgentCommands ? 'Ask to make changes, @mention files, run /commands' : 'Ask to make changes, @mention files'); const showFocusHint = !isInputFocused && !hasPendingClarification && !hasPendingComments; // Queue slot const hasQueuedMessage = isQueued && queuedMessage && onCancelQueue && updateQueueContent; const hasTodos = todoItems.length > 0; // Determine if context zone should show const hasContextZone = hasQueuedMessage || hasTodos || allItems.length > 0 || hasPendingClarification; if (isFailed) { return ( <>
This session has ended. Start a new session to continue.
); } return (
{/* Resize handle */} {/* Focus hint */} {/* CONTEXT ZONE: scrollable, above editor */} {hasContextZone && ( ) : undefined } todoSlot={hasTodos ? : undefined} clarificationSlot={ hasPendingClarification ? (
) : undefined } /> )} {/* EDITOR ZONE: flex-1, fills remaining space */}
setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} sessionId={sessionId} taskId={taskId} onAddContextFile={onAddContextFile} onToggleContextFile={onToggleContextFile} planContextEnabled={planContextEnabled} onAgentCommand={handleAgentCommand} onImagePaste={handleImagePaste} />

Write your changes here

{/* Integrated toolbar */}
); });