import React, { PureComponent } from 'react'; import Drawer from '@mui/material/Drawer'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Button from '@mui/material/Button'; import InputBase from '@mui/material/InputBase'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; import CircularProgress from '@mui/material/CircularProgress'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import SendRoundedIcon from '@mui/icons-material/SendRounded'; import AddRoundedIcon from '@mui/icons-material/AddRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import Tooltip from '@mui/material/Tooltip'; import Badge from '@mui/material/Badge'; import Divider from '@mui/material/Divider'; import Dialog from '@mui/material/Dialog'; import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded'; import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded'; import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded'; import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded'; import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded'; import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded'; import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded'; import { setting as settingApi } from '../../api'; import chatAutomat, { selectPendingAskUser, } from '../../automats/chatAutomat'; import companyAutomat from '../../automats/companyAutomat'; import vfsAutomat from '../../automats/vfsAutomat'; import { VfsEditorPanel, VfsFileIcon } from '../vfs'; import ChatFlowList from './ChatFlowList'; import AskUserPrompt from './AskUserPrompt'; import { ACCEPT_STRING, readFileAttachment } from './chatAttachments'; import { ChatStagedAttachmentsTray } from './ChatAttachmentItems'; import ChatMentionAutocomplete from './ChatMentionAutocomplete'; import { getToolMentionToken, getFileMentionToken, searchTools } from './chatTools.jsx'; import { BLUE_GLOW, DIALOG_FOG, SHADOW_DRAWER, } from '../../colors'; const DEFAULT_DRAWER_WIDTH = 460; const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL"; /** Sort by updated_at desc; subagents (started_by set) directly below their parent. */ function sortConversations(list) { const ts = (c) => new Date(c.updated_at || c.created_at || 0).getTime() || 0; const byUpdated = (a, b) => ts(b) - ts(a); const ids = new Set(list.map((c) => c.id)); const children = new Map(); const roots = []; for (const c of list) { if (c.started_by != null && ids.has(c.started_by)) { if (!children.has(c.started_by)) children.set(c.started_by, []); children.get(c.started_by).push(c); } else { roots.push(c); } } const out = []; const walk = (c, depth) => { out.push({ conv: c, depth }); (children.get(c.id) || []).sort(byUpdated).forEach((k) => walk(k, depth + 1)); }; roots.sort(byUpdated).forEach((c) => walk(c, 0)); return out; } function formatUpdated(value) { if (!value) return ''; const d = new Date(value); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }); } function conversationTitle(c) { let msg = (c.first_message || '').trim(); if (!msg) return '…'; const extMatch = msg.match(/\[EXTRACTION(?:\s+label="([^"]*)")?\]|/i); const hasInvoiceSkill = msg.includes("Process Incoming Product and Invoices"); let stripped = msg .replace(/\[EXTRACTION(?:\s+label="[^"]*")?\][\s\S]*?\[\/EXTRACTION\]/gi, '') .replace(/[\s\S]*?<\/extraction>/gi, '') .replace(/```[\s\S]*?```/g, '') .replace(/use 'Process Incoming Product and Invoices' SKILL.*/gi, '') .replace(/[\s\S]*?<\/USERENV>/gi, '') .replace(/\s+/g, ' ') .trim(); if (extMatch || hasInvoiceSkill) { const label = extMatch?.[1] || extMatch?.[2] || 'parsed invoice'; if (stripped) { return `${label}: ${stripped}`; } return label; } let clean = msg .replace(/[\s\S]*?<\/USERENV>/gi, '') .replace(/```[\s\S]*?```/g, '') .replace(/\s+/g, ' ') .trim(); return clean || msg.slice(0, 40) || '…'; } const MIN_DRAWER_WIDTH = 320; export default class ChatDrawer extends PureComponent { constructor(props) { super(props); const s = chatAutomat.state; const settings = companyAutomat.state; const vfs = vfsAutomat.state; this.state = { isOpen: s.isOpen, isVfsOpen: vfs.isOpen, vfsWidth: vfs.vfsWidth || 480, conversations: s.conversations, selectedConversationId: s.selectedConversationId, draftConversation: s.draftConversation, conversationState: s.conversationState, provider: s.provider, model: s.model, messages: s.messages, loadingMessages: s.loadingMessages, streamingText: s.streamingText, isSending: s.isSending, loadingConversations: s.loadingConversations, sseStatus: s.sseStatus, sendError: s.sendError, activeTurns: s.activeTurns || [], inputText: s.pendingInput != null ? s.pendingInput : '', isInputFocused: false, stagedAttachments: [], stagedReferences: [], stagedExtractions: s.pendingExtraction ? [ { id: `ext-${Date.now()}`, text: s.pendingExtraction.text, label: s.pendingExtraction.label || 'parsed invoice', instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION, }, ] : [], previewExtraction: null, isProcessingFiles: false, isDraggingFiles: false, attachmentError: null, drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH, isResizing: false, showSettingsBar: false, modelslugflag: 'floor', modelreasoning: 'medium', modelslug: 'openai/gpt-6-luna', vfsFiles: vfs.files || [], mentionOpen: false, mentionType: null, mentionQuery: '', mentionStartPos: 0, mentionSelectedIndex: 0, }; this.inputRef = React.createRef(); this.fileInputRef = React.createRef(); this.focusTimeout = null; } focusInput = (delay = 50) => { if (this.focusTimeout) clearTimeout(this.focusTimeout); this.focusTimeout = setTimeout(() => { this.inputRef.current?.focus?.(); }, delay); }; focusInputAtEnd = (delay = 50) => { if (this.focusTimeout) clearTimeout(this.focusTimeout); this.focusTimeout = setTimeout(() => { const el = this.inputRef.current; if (!el) return; el.focus?.(); if (typeof el.setSelectionRange === 'function' && el.value) { const len = el.value.length; el.setSelectionRange(len, len); } }, delay); }; updateGutterClass(isOpen) { if (typeof document === 'undefined') return; const html = document.documentElement; if (!html) return; if (isOpen) { html.classList.add('df-chat-open'); } else { html.classList.remove('df-chat-open'); } } componentDidMount() { this.updateGutterClass(this.state.isOpen); if (this.state.isOpen) { if (chatAutomat.state.pendingInput != null) { this.focusInputAtEnd(0); } else { this.focusInput(0); } } void this.loadModelSettings(); if (chatAutomat.state.pendingInput != null) { queueMicrotask(() => { if (chatAutomat.state.pendingInput != null) { chatAutomat.setState({ pendingInput: null }); } }); } if (chatAutomat.state.pendingExtraction != null) { queueMicrotask(() => { if (chatAutomat.state.pendingExtraction != null) { chatAutomat.setState({ pendingExtraction: null }); } }); } this.unsub = chatAutomat.subscribe(this, (state) => { const wasOpen = this.state.isOpen; const willOpen = state.isOpen; const patch = { isOpen: state.isOpen, conversations: state.conversations, selectedConversationId: state.selectedConversationId, draftConversation: state.draftConversation, conversationState: state.conversationState, provider: state.provider, model: state.model, messages: state.messages, loadingMessages: state.loadingMessages, streamingText: state.streamingText, isSending: state.isSending, loadingConversations: state.loadingConversations, sseStatus: state.sseStatus, sendError: state.sendError, activeTurns: state.activeTurns || [], }; if (state.pendingInput != null) { patch.inputText = this.state.inputText ? `${this.state.inputText}\n\n${state.pendingInput}` : state.pendingInput; queueMicrotask(() => { if (chatAutomat.state.pendingInput != null) { chatAutomat.setState({ pendingInput: null }); } }); this.focusInputAtEnd(0); } else if (!wasOpen && willOpen) { this.focusInput(0); } if (state.pendingExtraction != null) { const ext = state.pendingExtraction; const newExt = { id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, text: ext.text, label: ext.label || 'parsed invoice', instruction: ext.instruction || EXTRACTION_MODEL_INSTRUCTION, }; patch.stagedExtractions = [...(this.state.stagedExtractions || []), newExt]; queueMicrotask(() => { if (chatAutomat.state.pendingExtraction != null) { chatAutomat.setState({ pendingExtraction: null }); } }); this.focusInput(0); } return patch; }); this.unsubSettings = companyAutomat.subscribe(this, (state) => { if (!this.state.isResizing) { return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH }; } return null; }, ['chatDrawerWidth']); this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({ isVfsOpen: state.isOpen, vfsWidth: state.vfsWidth, vfsFiles: state.files, })); } componentDidUpdate(prevProps, prevState) { if (this.state.isOpen !== prevState.isOpen) { this.updateGutterClass(this.state.isOpen); } } componentWillUnmount() { this.updateGutterClass(false); if (this.focusTimeout) clearTimeout(this.focusTimeout); this.unsub?.(); this.unsubSettings?.(); this.unsubVfs?.(); this.cleanupResizeListeners(); } handleClose = () => { chatAutomat.actions.closeChat(); }; handleInsertToChat = (text) => { // If it's a file / line tag, e.g. [file.js#L10-L20](file.js#L10-L20) or [file.js](file.js) const match = typeof text === 'string' && text.trim().match(/^(!?)\[([^#\]]+)(?:#L?(\d+)(?:-L?(\d+))?)?\]\(([^)]+)\)$/); if (match) { const [, isImg, fileName, startLine, endLine, path] = match; this.handleMentionReference({ fileName, path, startLine: startLine ? parseInt(startLine, 10) : null, endLine: endLine ? parseInt(endLine, 10) : (startLine ? parseInt(startLine, 10) : null), isImage: Boolean(isImg), }); return; } this.setState((prev) => ({ inputText: prev.inputText ? `${prev.inputText}\n${text}` : text, })); this.focusInput(50); }; handleMentionReference = (refData) => { if (!refData || !refData.fileName) return; const { fileName, path = refData.fileName, startLine = null, endLine = null, selectedText = '', isImage = false, } = refData; this.setState((prev) => { const exists = prev.stagedReferences.some( (r) => r.fileName === fileName && r.startLine === startLine && r.endLine === endLine ); if (exists) { return null; } const newRef = { id: `ref-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, fileName, path, startLine, endLine, selectedText, isImage, }; return { stagedReferences: [...prev.stagedReferences, newRef], }; }); this.focusInput(50); }; handleRemoveReference = (id) => { this.setState((prev) => ({ stagedReferences: prev.stagedReferences.filter((r) => r.id !== id), })); this.focusInput(50); }; handleRemoveExtraction = (id) => { this.setState((prev) => ({ stagedExtractions: (prev.stagedExtractions || []).filter((e) => e.id !== id), })); this.focusInput(50); }; handlePreviewExtraction = (ext) => { console.log('[ChatDrawer] Clicked extraction pill:', ext); this.setState({ previewExtraction: ext }); }; handleCloseExtractionPreview = () => { this.setState({ previewExtraction: null }); }; handleCopyExtractionText = () => { const text = this.state.previewExtraction?.text; if (text && navigator?.clipboard?.writeText) { navigator.clipboard.writeText(text); } }; handleVfsResizerStart = (e) => { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX); if (startX === undefined) return; const startVfsWidth = this.state.vfsWidth; const startChatWidth = this.state.drawerWidth; const totalPairWidth = startVfsWidth + startChatWidth; this.setState({ isResizing: true }); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; let currentChatWidth = startChatWidth; let currentVfsWidth = startVfsWidth; const onMove = (moveEvt) => { moveEvt.preventDefault(); const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX); if (clientX === undefined) return; const deltaX = clientX - startX; const minVfs = 260; const minChat = MIN_DRAWER_WIDTH; // 320 // Drag right (deltaX > 0): VFS increases, Chat decreases // Drag left (deltaX < 0): VFS decreases, Chat increases const clampedVfs = Math.max(minVfs, Math.min(totalPairWidth - minChat, Math.round(startVfsWidth + deltaX))); const clampedChat = totalPairWidth - clampedVfs; currentVfsWidth = clampedVfs; currentChatWidth = clampedChat; vfsAutomat.actions.setVfsWidth(clampedVfs); this.setState({ drawerWidth: clampedChat }); }; const onEnd = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onEnd); window.removeEventListener('pointercancel', onEnd); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onEnd); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onEnd); this.setState({ isResizing: false }); vfsAutomat.actions.setVfsWidth(currentVfsWidth); companyAutomat.actions.setChatDrawerWidth(currentChatWidth); }; window.addEventListener('pointermove', onMove, { passive: false }); window.addEventListener('pointerup', onEnd); window.addEventListener('pointercancel', onEnd); window.addEventListener('mousemove', onMove, { passive: false }); window.addEventListener('mouseup', onEnd); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onEnd); }; handleResizeStart = (e) => { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX); if (startX === undefined) return; const startWidth = this.state.drawerWidth; const startVfsWidth = this.state.vfsWidth; const isVfs = this.state.isVfsOpen; this.setState({ isResizing: true }); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; let currentChatWidth = startWidth; let currentVfsWidth = startVfsWidth; const onMove = (moveEvt) => { moveEvt.preventDefault(); const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX); if (clientX === undefined) return; const deltaX = startX - clientX; if (isVfs) { const maxVfs = Math.min(window.innerWidth - startWidth - 40, 1200); const newVfsWidth = Math.max(260, Math.min(maxVfs, Math.round(startVfsWidth + deltaX))); currentVfsWidth = newVfsWidth; vfsAutomat.actions.setVfsWidth(newVfsWidth); } else { const maxWidth = Math.min(window.innerWidth - 40, 1400); const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX))); currentChatWidth = newWidth; this.setState({ drawerWidth: newWidth }); } }; const onEnd = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onEnd); window.removeEventListener('pointercancel', onEnd); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onEnd); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onEnd); this.setState({ isResizing: false }); if (isVfs) { vfsAutomat.actions.setVfsWidth(currentVfsWidth); } else { companyAutomat.actions.setChatDrawerWidth(currentChatWidth); } }; window.addEventListener('pointermove', onMove, { passive: false }); window.addEventListener('pointerup', onEnd); window.addEventListener('pointercancel', onEnd); window.addEventListener('mousemove', onMove, { passive: false }); window.addEventListener('mouseup', onEnd); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onEnd); }; handleResizeDoubleClick = () => { this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH }); companyAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH); }; cleanupResizeListeners = () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; }; handleSelectConversation = (e) => { const id = Number(e.target.value); if (id) { chatAutomat.actions.selectConversation(id); this.focusInput(50); } }; handleNewChat = () => { chatAutomat.actions.startDraft(); this.focusInput(50); }; handleDeleteConversation = async (e, convId) => { e?.stopPropagation(); e?.preventDefault(); if (!convId) return; try { await chatAutomat.actions.deleteConversation(convId); } catch (err) { console.error('[ChatDrawer] Failed to delete conversation:', err); } finally { this.focusInput(50); } }; handleSend = async (e) => { e?.preventDefault(); const rawText = this.state.inputText.trim(); const attachments = this.state.stagedAttachments || []; const references = this.state.stagedReferences || []; const extractions = this.state.stagedExtractions || []; if ( (!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) || this.state.conversationState === 'busy' || this.state.isSending ) { return; } let text = rawText; if (extractions.length > 0) { const instructions = extractions .map((e) => e.instruction) .filter(Boolean); const uniqueInstructions = Array.from(new Set(instructions)); const instructionText = uniqueInstructions.join('\n\n') || EXTRACTION_MODEL_INSTRUCTION; const extBlocks = extractions .map( (ext) => `[EXTRACTION label="${ext.label || 'parsed invoice'}"]\n\`\`\`\n${ext.text.trim()}\n\`\`\`\n[/EXTRACTION]` ) .join('\n\n'); if (text) { text = `${instructionText}\n\n${text}\n\n${extBlocks}`; } else { text = `${instructionText}\n\n${extBlocks}`; } } if (references.length > 0) { const refMarkdown = references .map((ref) => { const lineRef = ref.startLine ? (ref.startLine === ref.endLine ? `L${ref.startLine}` : `L${ref.startLine}-L${ref.endLine}`) : null; const lineLabel = ref.startLine ? (ref.startLine === ref.endLine ? `:${ref.startLine}` : `:${ref.startLine}-${ref.endLine}`) : ''; const link = ref.isImage && !lineRef ? `![${ref.fileName}](${ref.path || ref.fileName})` : `[${ref.fileName}${lineLabel}](${ref.path || ref.fileName}${lineRef ? `#${lineRef}` : ''})`; if (ref.selectedText && ref.selectedText.trim()) { const ext = ref.fileName.split('.').pop() || ''; return `${link}\n\`\`\`${ext}\n${ref.selectedText.trim()}\n\`\`\``; } return link; }) .join('\n\n'); text = text ? `${refMarkdown}\n\n${text}` : refMarkdown; } this.setState({ inputText: '', stagedAttachments: [], stagedReferences: [], stagedExtractions: [], attachmentError: null, mentionOpen: false, mentionType: null, mentionQuery: '', mentionSelectedIndex: 0, }); try { await chatAutomat.actions.sendMessage(text, attachments); } catch { // Error is tracked in chatAutomat state (sendError) } }; handleOpenFileDialog = () => { this.fileInputRef.current?.click(); }; handleFileSelect = (e) => { const files = e.target?.files; if (files && files.length > 0) { void this.handleFiles(Array.from(files)); } if (e.target) { e.target.value = ''; } }; handleFiles = async (fileList) => { if (!fileList || fileList.length === 0) return; this.setState({ isProcessingFiles: true, attachmentError: null }); const newAttachments = []; const errors = []; for (const file of fileList) { try { const att = await readFileAttachment(file); newAttachments.push(att); } catch (err) { errors.push(err.message || `Datei "${file.name}" konnte nicht verarbeitet werden.`); } } this.setState((prev) => ({ stagedAttachments: [...prev.stagedAttachments, ...newAttachments], isProcessingFiles: false, attachmentError: errors.length > 0 ? errors.join(' ') : null, })); this.focusInput(50); }; handleRemoveAttachment = (idOrIdx) => { this.setState((prev) => ({ stagedAttachments: prev.stagedAttachments.filter((a, idx) => (a.id ? a.id !== idOrIdx : idx !== idOrIdx)), })); }; handlePaste = (e) => { const items = e.clipboardData?.items; if (!items || items.length === 0) return; const files = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) files.push(file); } } if (files.length > 0) { e.preventDefault(); void this.handleFiles(files); } }; handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingFiles: true }); } }; handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!this.state.isDraggingFiles && e.dataTransfer?.types?.includes('Files')) { this.setState({ isDraggingFiles: true }); } }; handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; this.setState({ isDraggingFiles: false }); }; handleDrop = (e) => { e.preventDefault(); e.stopPropagation(); this.setState({ isDraggingFiles: false }); const files = e.dataTransfer?.files; if (files && files.length > 0) { void this.handleFiles(Array.from(files)); } }; updateMentionState = (value, cursorPos) => { const textBeforeCursor = value.slice(0, cursorPos); const mentionMatch = textBeforeCursor.match(/(?:^|\s)([@§#])(?:\[([^\]]*)|\"([^\"]*)|([^\s@§#]*))$/); if (mentionMatch) { const symbol = mentionMatch[1]; const query = mentionMatch[2] ?? mentionMatch[3] ?? mentionMatch[4] ?? ''; const symbolIndex = textBeforeCursor.lastIndexOf(symbol); const isFile = symbol === '@'; const isTool = symbol === '§' || symbol === '#'; if (isFile && (!this.state.vfsFiles || this.state.vfsFiles.length === 0)) { void vfsAutomat.actions.loadFiles(); } this.setState({ inputText: value, mentionOpen: true, mentionType: isFile ? 'file' : isTool ? 'tool' : null, mentionQuery: query, mentionStartPos: symbolIndex, mentionSelectedIndex: 0, }); } else { this.setState({ inputText: value, mentionOpen: false, mentionType: null, mentionQuery: '', mentionSelectedIndex: 0, }); } }; handleInputChange = (e) => { const value = e.target.value; const cursorPos = e.target.selectionStart ?? value.length; this.updateMentionState(value, cursorPos); }; handleInputSelect = (e) => { const cursorPos = e.target.selectionStart; if (cursorPos != null && this.state.mentionOpen) { this.updateMentionState(this.state.inputText, cursorPos); } }; handleMentionSelect = (item) => { if (!item) return; const { inputText, mentionType, mentionStartPos } = this.state; const inputEl = this.inputRef.current; const cursorPos = inputEl?.selectionStart ?? inputText.length; let token = ''; if (mentionType === 'tool') { token = getToolMentionToken(item); } else if (mentionType === 'file') { token = getFileMentionToken(item.name || item); } const before = inputText.slice(0, mentionStartPos); const after = inputText.slice(cursorPos); const replacement = `${token} `; const newText = `${before}${replacement}${after}`; const newCursorPos = before.length + replacement.length; this.setState( { inputText: newText, mentionOpen: false, mentionType: null, mentionQuery: '', mentionSelectedIndex: 0, }, () => { if (inputEl) { inputEl.focus(); try { inputEl.selectionStart = newCursorPos; inputEl.selectionEnd = newCursorPos; } catch { } } } ); }; handleKeyDown = (e) => { const { mentionOpen, mentionType, mentionQuery, mentionSelectedIndex, vfsFiles } = this.state; if (mentionOpen && mentionType) { let items = []; if (mentionType === 'tool') { items = searchTools(mentionQuery); } else { const q = (mentionQuery || '').toLowerCase().trim(); items = (vfsFiles || []).filter((f) => !q || (f.name || '').toLowerCase().includes(q)); } if (e.key === 'ArrowDown') { e.preventDefault(); if (items.length > 0) { this.setState({ mentionSelectedIndex: (mentionSelectedIndex + 1) % items.length, }); } return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (items.length > 0) { this.setState({ mentionSelectedIndex: (mentionSelectedIndex - 1 + items.length) % items.length, }); } return; } if (e.key === 'Enter' || e.key === 'Tab') { if (items.length > 0) { e.preventDefault(); const selected = items[mentionSelectedIndex] || items[0]; this.handleMentionSelect(selected); return; } } if (e.key === 'Escape') { e.preventDefault(); this.setState({ mentionOpen: false, mentionType: null }); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void this.handleSend(); } }; handleAnswerTool = async ({ tool_call_id, answer }) => { try { await chatAutomat.actions.sendToolResponse({ tool_call_id, answer }); } catch (err) { console.error('[ChatDrawer] Failed to send tool response:', err); } }; loadModelSettings = async () => { try { const data = await settingApi.list(); const settings = data?.settings || []; const flagSetting = settings.find((s) => s.key === 'modelslugflag'); const reasoningSetting = settings.find((s) => s.key === 'modelreasoning'); const slugSetting = settings.find((s) => s.key === 'modelslug'); this.setState({ modelslugflag: flagSetting?.value ?? 'floor', modelreasoning: reasoningSetting?.value ?? 'medium', modelslug: slugSetting?.value ?? 'openai/gpt-6-luna', }); } catch (err) { console.error('[ChatDrawer] Failed to load model settings:', err); } }; handleToggleSettingsBar = () => { this.setState((prev) => ({ showSettingsBar: !prev.showSettingsBar })); }; handleSetFlag = async (nextFlag) => { if (this.state.modelslugflag === nextFlag) return; const current = this.state.modelslugflag; this.setState({ modelslugflag: nextFlag }); try { await settingApi.upsert('modelslugflag', { value: nextFlag }); } catch (err) { console.error('[ChatDrawer] Failed to save modelslugflag:', err); this.setState({ modelslugflag: current }); } }; handleSetReasoning = async (nextReasoning) => { if (this.state.modelreasoning === nextReasoning) return; const current = this.state.modelreasoning; this.setState({ modelreasoning: nextReasoning }); try { await settingApi.upsert('modelreasoning', { value: nextReasoning }); } catch (err) { console.error('[ChatDrawer] Failed to save modelreasoning:', err); this.setState({ modelreasoning: current }); } }; handleSetModelslug = async (nextSlug) => { if (this.state.modelslug === nextSlug) return; const current = this.state.modelslug; this.setState({ modelslug: nextSlug }); try { await settingApi.upsert('modelslug', { value: nextSlug }); } catch (err) { console.error('[ChatDrawer] Failed to save modelslug:', err); this.setState({ modelslug: current }); } }; handleModelslugChange = (e) => { this.setState({ modelslug: e.target.value }); }; handleModelslugBlur = async () => { const { modelslug } = this.state; const trimmed = (modelslug || '').trim(); try { await settingApi.upsert('modelslug', { value: trimmed }); } catch (err) { console.error('[ChatDrawer] Failed to save modelslug:', err); } }; handleModelslugKeyDown = (e) => { if (e.key === 'Enter') { e.target.blur(); } }; getPendingAskUser = () => selectPendingAskUser(this.state); renderHeader() { const { conversations, selectedConversationId, loadingConversations, showSettingsBar, modelslugflag, modelreasoning, modelslug, isVfsOpen, } = this.state; return ( {showSettingsBar ? ( /* Settings Bar Mode: Title goes away and reveals toggles */ {/* Group 1: Speed (floor, walk/standard "", run/nitro) */} (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'), p: 0.3, borderRadius: 2, flexShrink: 0, }} > this.handleSetFlag('floor')} aria-label="Floor" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslugflag === 'floor' ? 'primary.main' : 'transparent', color: modelslugflag === 'floor' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslugflag === 'floor' ? 'primary.dark' : 'action.selected', }, }} > this.handleSetFlag('')} aria-label="Normal" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslugflag === '' ? 'primary.main' : 'transparent', color: modelslugflag === '' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslugflag === '' ? 'primary.dark' : 'action.selected', }, }} > this.handleSetFlag('nitro')} aria-label="Nitro" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslugflag === 'nitro' ? 'primary.main' : 'transparent', color: modelslugflag === 'nitro' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslugflag === 'nitro' ? 'primary.dark' : 'action.selected', }, }} > {/* Group 2: Reasoning (none, low, medium, max) */} (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'), p: 0.3, borderRadius: 2, flexShrink: 0, }} > {/* none toggle (crossed out brain) */} this.handleSetReasoning('none')} aria-label="Reasoning None" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelreasoning === 'none' ? 'primary.main' : 'transparent', color: modelreasoning === 'none' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelreasoning === 'none' ? 'primary.dark' : 'action.selected', }, }} > {/* low toggle (smallest brain) */} this.handleSetReasoning('low')} aria-label="Reasoning Low" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelreasoning === 'low' ? 'primary.main' : 'transparent', color: modelreasoning === 'low' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelreasoning === 'low' ? 'primary.dark' : 'action.selected', }, }} > {/* medium toggle (medium brain) */} this.handleSetReasoning('medium')} aria-label="Reasoning Medium" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelreasoning === 'medium' ? 'primary.main' : 'transparent', color: modelreasoning === 'medium' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelreasoning === 'medium' ? 'primary.dark' : 'action.selected', }, }} > {/* max toggle (big brain) */} this.handleSetReasoning('high')} aria-label="Reasoning Max" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelreasoning === 'high' ? 'primary.main' : 'transparent', color: modelreasoning === 'high' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelreasoning === 'high' ? 'primary.dark' : 'action.selected', }, }} > {/* Group 3: Model preset shortcuts (moon -> luna, sun -> sol, universe -> astra) */} (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'), p: 0.3, borderRadius: 2, flexShrink: 0, }} > {/* moon toggle (openai/gpt-6-luna) */} this.handleSetModelslug('openai/gpt-6-luna')} aria-label="Luna (openai/gpt-6-luna)" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.main' : 'transparent', color: modelslug === 'openai/gpt-6-luna' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.dark' : 'action.selected', }, }} > {/* sun toggle (openai/gpt-6.1-sol) */} this.handleSetModelslug('openai/gpt-6.1-sol')} aria-label="Sol (openai/gpt-6.1-sol)" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.main' : 'transparent', color: modelslug === 'openai/gpt-6.1-sol' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.dark' : 'action.selected', }, }} > {/* universe toggle (anthropic/claude-haiku-5.5) */} this.handleSetModelslug('anthropic/claude-haiku-5.5')} aria-label="Haiku (anthropic/claude-haiku-5.5)" sx={{ p: 0.3, width: 26, height: 26, borderRadius: 1.5, bgcolor: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.main' : 'transparent', color: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.contrastText' : 'text.secondary', '&:hover': { bgcolor: modelslug === 'anthropic/claude-haiku-5.5' ? 'primary.dark' : 'action.selected', }, }} > {/* modelslug input */} (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'), borderRadius: 1.5, border: '1px solid transparent', '&:focus-within': { borderColor: 'primary.main', bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#fff'), }, '& input': { p: 0, overflow: 'hidden', textOverflow: 'ellipsis', }, }} /> ) : ( /* Normal Mode: settings icon on the left end, title visible */ KI-Assistent )} vfsAutomat.actions.toggleVfs()} aria-label="Dateien" sx={{ p: 0.5, color: isVfsOpen ? 'primary.main' : 'text.secondary', bgcolor: isVfsOpen ? 'action.selected' : 'transparent', borderRadius: 1.5, border: '1px solid', borderColor: isVfsOpen ? 'primary.main' : 'transparent', '&:hover': { color: 'primary.main', bgcolor: 'action.hover', }, }} > {/* Conversation Selector Bar */} {conversations.length > 0 && ( Konversation this.handleDeleteConversation(e, selectedConversationId)} aria-label="Konversation löschen" sx={{ borderRadius: 2, border: 1, borderColor: 'divider', p: 0.9, flexShrink: 0, }} > )} ); } renderBody() { const { conversations, selectedConversationId, draftConversation, conversationState, messages, streamingText, loadingConversations, model, } = this.state; if (loadingConversations && conversations.length === 0) { return (<>) } if (conversations.length === 0 && !draftConversation) { return (<>) } const pendingAsk = this.getPendingAskUser(); return ( {messages.length === 0 ? ( ) : ( )} {pendingAsk && ( )} ); } renderFooter() { const { conversationState, isSending, selectedConversationId, draftConversation, inputText, isInputFocused, sendError, stagedAttachments, stagedReferences, stagedExtractions = [], isProcessingFiles, isDraggingFiles, attachmentError, } = this.state; const pendingAsk = this.getPendingAskUser(); const isBusy = conversationState === 'busy'; const isWorking = isBusy || isSending; const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk; const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0 || stagedExtractions.length > 0); return ( {sendError && ( {sendError} chatAutomat.setState({ sendError: null })} sx={{ color: 'inherit', p: 0.2 }} > )} {attachmentError && ( {attachmentError} this.setState({ attachmentError: null })} sx={{ color: 'inherit', p: 0.2 }} > )} {/* Staged attachments tray */} {isProcessingFiles && ( Dateien werden eingelesen… )} {isDraggingFiles && ( Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML) )} this.setState({ mentionSelectedIndex: idx })} onClose={() => this.setState({ mentionOpen: false, mentionType: null })} /> {/* Attachment Icon Button */} 0 ? 'primary.main' : 'text.secondary', bgcolor: stagedAttachments.length > 0 ? 'action.selected' : 'transparent', border: '1px solid', borderColor: stagedAttachments.length > 0 ? 'primary.main' : 'transparent', flexShrink: 0, '&:hover': { bgcolor: 'action.hover', color: 'primary.main', }, }} > {/* Hidden File Input */} {/* Unified Copilot-style input container with context reference pills */} { if (this.inputRef.current && (e.target === this.inputRef.current || this.inputRef.current.contains(e.target))) { return; } this.focusInput(0); }} sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper', border: '1px solid', borderColor: isInputFocused ? 'primary.main' : 'divider', boxShadow: isInputFocused ? (theme) => `0 0 0 1px ${theme.palette.primary.main}` : 'none', borderRadius: 2.5, p: 0.75, gap: 0.5, cursor: 'text', transition: 'border-color 0.15s, box-shadow 0.15s', minHeight: 40, boxSizing: 'border-box', }} > {(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && ( {stagedReferences.map((ref) => ( { e.stopPropagation(); vfsAutomat.actions.openVfs(); vfsAutomat.actions.selectFile(ref.fileName); }} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.6, py: 0.2, pl: 0.7, pr: 0.4, borderRadius: 1.5, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)', border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)', color: 'text.primary', fontSize: '0.75rem', lineHeight: 1.2, userSelect: 'none', cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.09)', borderColor: 'primary.light', }, }} > {ref.fileName} {ref.startLine && ( :{ref.startLine === ref.endLine ? ref.startLine : `${ref.startLine}-${ref.endLine}`} )} { e.stopPropagation(); this.handleRemoveReference(ref.id); }} aria-label="Referenz entfernen" sx={{ p: 0.2, ml: 0.1, color: 'text.secondary', '&:hover': { color: 'error.main', bgcolor: 'action.hover', }, }} > ))} {stagedExtractions && stagedExtractions.map((ext) => ( { e.stopPropagation(); this.handlePreviewExtraction(ext); }} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.6, py: 0.2, pl: 0.7, pr: 0.4, borderRadius: 1.5, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)', border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)', color: 'text.primary', fontSize: '0.75rem', lineHeight: 1.2, userSelect: 'none', cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.09)', borderColor: 'primary.light', }, }} > {ext.label || 'parsed invoice'} { e.stopPropagation(); this.handleRemoveExtraction(ext.id); }} aria-label="Entfernen" sx={{ p: 0.2, ml: 0.1, color: 'text.secondary', '&:hover': { color: 'error.main', bgcolor: 'action.hover', }, }} > ))} )} this.setState({ isInputFocused: true })} onBlur={() => { setTimeout(() => { this.setState({ isInputFocused: false }); }, 200); }} disabled={!canChat} inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off', autoComplete: 'off', }} placeholder={ pendingAsk ? 'Bitte Frage oben beantworten…' : stagedAttachments.length > 0 ? 'Nachricht hinzufügen oder direkt absenden…' : '@ Datei · § Werkzeug erwähnen…' } sx={{ px: 0.5, py: 0.1, fontSize: '0.875rem', lineHeight: 1.45, '& .MuiInputBase-input': { p: 0, }, }} /> {isWorking ? ( ) : ( )} ); } render() { const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state; const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth; return ( <> ({ zIndex: theme.zIndex.modal + 10, '& .MuiBackdrop-root': { backgroundColor: DIALOG_FOG, transition: 'none !important', }, '& .MuiDrawer-paper': { width: { xs: '100vw', sm: `${totalWidth}px` }, maxWidth: '100vw', display: 'flex', flexDirection: 'row', boxShadow: SHADOW_DRAWER, position: 'fixed', overflow: 'visible', transition: 'none !important', }, })} slotProps={{ transition: { timeout: 0, onEntered: () => this.focusInput(0), }, paper: { sx: { width: { xs: '100vw', sm: `${totalWidth}px` }, maxWidth: '100vw', display: 'flex', flexDirection: 'row', boxShadow: SHADOW_DRAWER, position: 'fixed', overflow: 'visible', transition: 'none !important', }, }, }} > {/* Draggable Resizer Handle */} {/* Left: VFS Filesystem Editor Panel (parallel to chat) */} {isVfsOpen && ( )} {/* Vertical Split Resizer between VFS and Chat (0px width so no layout shift or grey bar) */} {isVfsOpen && ( {/* Invisible wide hit area (16px wide, centered on the 1px border) */} {/* Visual handle indicator on hover / active */} )} {/* Right: Chat Panel */} {this.renderHeader()} {this.renderBody()} {this.renderFooter()} {/* Extraction Preview Dialog */} ({ zIndex: theme.zIndex.modal + 50, })} slotProps={{ paper: { sx: { borderRadius: 2.5, bgcolor: 'background.paper', backgroundImage: 'none', }, }, }} > {this.state.previewExtraction?.label || 'parsed invoice'} theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}> {this.state.previewExtraction?.text || ''} ); } }