Files
dashfront/src/components/chat/ChatDrawer.jsx
2026-10-16 16:03:31 +02:00

2125 lines
73 KiB
JavaScript

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,
selectPendingDraftRead,
} from '../../automats/chatAutomat';
import { getActiveDraftContent } from '../../automats/draftSync';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import DraftRequestPrompt from './DraftRequestPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
import { ChatStagedAttachmentsTray } from './ChatAttachmentItems';
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 to fill drafts/delivery vfs file";
/** 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="([^"]*)")?\]|<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(/<extraction(?:\s+label="[^"]*")?>[\s\S]*?<\/extraction>/gi, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/use 'Process Incoming Product and Invoices' SKILL to fill drafts\/delivery vfs file/gi, '')
.replace(/<USERENV>[\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(/<USERENV>[\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 = userSettingsAutomat.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',
};
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 = userSettingsAutomat.subscribe(this, (state) => {
if (!this.state.isResizing) {
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
}
return null;
});
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
isVfsOpen: state.isOpen,
vfsWidth: state.vfsWidth,
}));
}
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);
userSettingsAutomat.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 {
userSettingsAutomat.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 });
userSettingsAutomat.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,
});
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));
}
};
handleKeyDown = (e) => {
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);
getPendingDraftRead = () => selectPendingDraftRead(this.state);
handleProvideDraft = async ({ toolCallId, draftKey, empty = false }) => {
try {
let content;
if (empty) {
content = { error: 'Draft not open or empty' };
} else {
const manifest = getActiveDraftContent(draftKey);
content = {
manifest: manifest || {
kind: draftKey,
mode: 'create',
lines: [],
},
};
}
await chatAutomat.actions.sendToolResponse({
tool_call_id: toolCallId,
name: 'read_file',
content,
});
} catch (err) {
console.error('[ChatDrawer] Failed to provide draft response:', err);
}
};
renderHeader() {
const {
conversations,
selectedConversationId,
loadingConversations,
showSettingsBar,
modelslugflag,
modelreasoning,
modelslug,
isVfsOpen,
} = this.state;
return (
<Box
sx={{
p: 2,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: 34 }}>
{showSettingsBar ? (
/* Settings Bar Mode: Title goes away and reveals toggles */
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, overflow: 'hidden' }}>
<IconButton
size="small"
onClick={this.handleToggleSettingsBar}
aria-label="Einstellungen schließen"
sx={{
p: 0.5,
color: 'primary.main',
bgcolor: 'action.selected',
flexShrink: 0,
}}
>
<TuneRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
{/* Group 1: Speed (floor, walk/standard "", run/nitro) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
p: 0.3,
borderRadius: 2,
flexShrink: 0,
}}
>
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<ElderlyRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<DirectionsWalkRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<DirectionsRunRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
{/* Group 2: Reasoning (none, low, medium, max) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (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) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<PsychologyRoundedIcon sx={{ fontSize: 16 }} />
<Box
sx={{
position: 'absolute',
width: '125%',
height: '1.5px',
bgcolor: modelreasoning === 'none' ? 'primary.contrastText' : 'error.main',
transform: 'rotate(-45deg)',
borderRadius: 1,
}}
/>
</Box>
</IconButton>
{/* low toggle (smallest brain) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
{/* medium toggle (medium brain) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
{/* max toggle (big brain) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<PsychologyRoundedIcon sx={{ fontSize: 22 }} />
</IconButton>
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
{/* Group 3: Model preset shortcuts (moon -> luna, sun -> sol, universe -> astra) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (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) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<DarkModeRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
{/* sun toggle (openai/gpt-6.1-sol) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<LightModeRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
{/* universe toggle (anthropic/claude-haiku-5.5) */}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<CycloneRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
{/* modelslug input */}
<InputBase
value={modelslug || ''}
onChange={this.handleModelslugChange}
onBlur={this.handleModelslugBlur}
onKeyDown={this.handleModelslugKeyDown}
placeholder="Modellslug"
inputProps={{
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
autoComplete: 'off',
'aria-label': 'Modellslug',
}}
sx={{
flex: 1,
minWidth: 80,
fontSize: '0.78rem',
fontFamily: 'monospace',
px: 0.8,
py: 0.2,
bgcolor: (theme) => (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',
},
}}
/>
</Box>
) : (
/* Normal Mode: settings icon on the left end, title visible */
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={this.handleToggleSettingsBar}
aria-label="Modell-Einstellungen"
sx={{
p: 0.5,
color: 'text.secondary',
'&:hover': { color: 'primary.main', bgcolor: 'action.hover' },
}}
>
<TuneRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
<AutoAwesomeRoundedIcon sx={{ fontSize: 20, color: 'primary.main' }} />
<Typography variant="subtitle1" fontWeight={700}>
KI-Assistent
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1, flexShrink: 0 }}>
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<FolderOpenRoundedIcon sx={{ fontSize: 19 }} />
</IconButton>
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ p: 0.5 }}>
<CloseRoundedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Conversation Selector Bar */}
{conversations.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl fullWidth size="small">
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
<Select
labelId="chat-conversation-label"
value={selectedConversationId || ''}
label="Konversation"
onChange={this.handleSelectConversation}
disabled={loadingConversations || conversations.length === 0}
sx={{ borderRadius: 2 }}
MenuProps={{
sx: {
zIndex: (theme) => theme.zIndex.modal + 20,
},
}}
renderValue={(selectedId) => {
const conv = conversations.find((c) => c.id === selectedId);
if (!conv) return '';
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
<Typography variant="body2" fontWeight={600} noWrap sx={{ flexGrow: 1, minWidth: 0 }}>
{conversationTitle(conv)}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexShrink: 0 }}>
{formatUpdated(conv.updated_at)}
</Typography>
</Box>
);
}}
>
{sortConversations(conversations).map(({ conv: c, depth }) => (
<MenuItem key={c.id} value={c.id} sx={{ pl: 2 + depth * 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
{depth > 0 && (
<Typography variant="caption" color="text.secondary">↳</Typography>
)}
<Typography
variant="body2"
fontWeight={depth > 0 ? 400 : 600}
noWrap
sx={{ flexGrow: 1, minWidth: 0 }}
>
{conversationTitle(c)}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexShrink: 0 }}>
{formatUpdated(c.updated_at)}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
<IconButton
size="small"
color="error"
disabled={!selectedConversationId}
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
aria-label="Konversation löschen"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<DeleteOutlineRoundedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="primary"
disabled={!selectedConversationId}
onClick={this.handleNewChat}
aria-label="Neuer Chat"
sx={{
borderRadius: 2,
border: 1,
borderColor: 'divider',
p: 0.9,
flexShrink: 0,
}}
>
<AddRoundedIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
}
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();
const pendingDraftRead = this.getPendingDraftRead();
return (
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{messages.length === 0 ? (
<Box sx={{ flexGrow: 1 }} />
) : (
<ChatFlowList
messages={
streamingText && (!this.state.activeTurns || this.state.activeTurns.length === 0)
? [
...messages,
{
id: 'streaming',
conversation_id: selectedConversationId,
role: 'assistant',
message: streamingText,
streaming: true,
},
]
: messages
}
activeTurns={this.state.activeTurns}
conversationState={conversationState}
model={model}
/>
)}
{pendingAsk && (
<AskUserPrompt
askData={pendingAsk}
onAnswer={this.handleAnswerTool}
disabled={this.state.isSending}
/>
)}
{pendingDraftRead && (
<DraftRequestPrompt
draftData={pendingDraftRead}
onProvideDraft={this.handleProvideDraft}
disabled={this.state.isSending}
/>
)}
</Box>
);
}
renderFooter() {
const {
conversationState,
isSending,
selectedConversationId,
draftConversation,
inputText,
isInputFocused,
sendError,
stagedAttachments,
stagedReferences,
stagedExtractions = [],
isProcessingFiles,
isDraggingFiles,
attachmentError,
} = this.state;
const pendingAsk = this.getPendingAskUser();
const pendingDraftRead = this.getPendingDraftRead();
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat =
(Boolean(selectedConversationId) || draftConversation) &&
!pendingAsk &&
!pendingDraftRead;
const canSend =
!isWorking &&
canChat &&
(Boolean(inputText.trim()) ||
stagedAttachments.length > 0 ||
stagedReferences.length > 0 ||
stagedExtractions.length > 0);
return (
<Box
component="form"
onSubmit={this.handleSend}
noValidate
autoComplete="off"
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onDrop={this.handleDrop}
sx={{
p: 2,
borderTop: 1,
borderColor: isDraggingFiles ? 'primary.main' : 'divider',
bgcolor: isDraggingFiles ? 'action.hover' : 'background.paper',
transition: 'border-color 0.15s, background-color 0.15s',
display: 'flex',
flexDirection: 'column',
gap: 1,
position: 'relative',
}}
>
{sendError && (
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: 'error.light',
color: 'error.contrastText',
fontSize: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption">{sendError}</Typography>
<IconButton
size="small"
onClick={() => chatAutomat.setState({ sendError: null })}
sx={{ color: 'inherit', p: 0.2 }}
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
{attachmentError && (
<Box
sx={{
p: 1,
borderRadius: 1.5,
bgcolor: 'warning.light',
color: 'warning.contrastText',
fontSize: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption">{attachmentError}</Typography>
<IconButton
size="small"
onClick={() => this.setState({ attachmentError: null })}
sx={{ color: 'inherit', p: 0.2 }}
>
<CloseRoundedIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
)}
{/* Staged attachments tray */}
<ChatStagedAttachmentsTray
attachments={stagedAttachments}
onRemove={this.handleRemoveAttachment}
/>
{isProcessingFiles && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1 }}>
<CircularProgress size={14} thickness={5} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.72rem' }}>
Dateien werden eingelesen…
</Typography>
</Box>
)}
{isDraggingFiles && (
<Box
sx={{
p: 1.5,
border: '2px dashed',
borderColor: 'primary.main',
borderRadius: 2,
textAlign: 'center',
bgcolor: 'action.selected',
color: 'primary.main',
fontWeight: 500,
fontSize: '0.85rem',
}}
>
Dateien hier ablegen (Bilder, PDFs, CSV, Text, XML, HTML)
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
{/* Attachment Icon Button */}
<Tooltip title="Dateien anhängen (Bilder, PDFs, CSV, Text, XML, HTML)">
<span>
<IconButton
size="small"
onClick={this.handleOpenFileDialog}
disabled={!canChat || isWorking}
aria-label="Dateien anhängen"
sx={{
width: 40,
height: 40,
borderRadius: 2.5,
color: stagedAttachments.length > 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',
},
}}
>
<Badge badgeContent={stagedAttachments.length} color="primary">
<AttachFileRoundedIcon sx={{ fontSize: 22 }} />
</Badge>
</IconButton>
</span>
</Tooltip>
{/* Hidden File Input */}
<input
ref={this.fileInputRef}
type="file"
multiple
accept={ACCEPT_STRING}
onChange={this.handleFileSelect}
style={{ display: 'none' }}
/>
{/* Unified Copilot-style input container with context reference pills */}
<Box
onClick={(e) => {
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)) && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.6,
alignItems: 'center',
mb: 0.25,
}}
>
{stagedReferences.map((ref) => (
<Box
key={ref.id}
onClick={(e) => {
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',
},
}}
>
<VfsFileIcon fileName={ref.fileName} sx={{ fontSize: 14 }} />
<Typography
component="span"
sx={{
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
fontSize: '0.74rem',
fontWeight: 600,
letterSpacing: '-0.2px',
display: 'inline-flex',
alignItems: 'center',
}}
>
{ref.fileName}
{ref.startLine && (
<Box
component="span"
sx={{
color: 'primary.main',
fontWeight: 700,
ml: 0.3,
}}
>
:{ref.startLine === ref.endLine ? ref.startLine : `${ref.startLine}-${ref.endLine}`}
</Box>
)}
</Typography>
<IconButton
size="small"
onClick={(e) => {
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',
},
}}
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
))}
{stagedExtractions && stagedExtractions.map((ext) => (
<Box
key={ext.id}
onClick={(e) => {
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',
},
}}
>
<DescriptionOutlinedIcon sx={{ fontSize: 14, color: 'primary.main' }} />
<Typography
component="span"
sx={{
fontSize: '0.74rem',
fontWeight: 600,
letterSpacing: '-0.2px',
}}
>
{ext.label || 'parsed invoice'}
</Typography>
<IconButton
size="small"
onClick={(e) => {
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',
},
}}
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
))}
</Box>
)}
<InputBase
inputRef={this.inputRef}
multiline
minRows={1}
maxRows={5}
fullWidth
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onFocus={() => this.setState({ isInputFocused: true })}
onBlur={() => this.setState({ isInputFocused: false })}
disabled={!canChat}
inputProps={{
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
autoComplete: 'off',
}}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: pendingDraftRead
? 'Bitte Entwurfsanfrage oben beantworten…'
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
}
sx={{
px: 0.5,
py: 0.1,
fontSize: '0.875rem',
lineHeight: 1.45,
'& .MuiInputBase-input': {
p: 0,
},
}}
/>
</Box>
<IconButton
type="submit"
color="primary"
disabled={!canSend}
title={
isWorking
? 'Modell antwortet gerade…'
: 'Senden (Enter)'
}
sx={{
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
width: 40,
height: 40,
borderRadius: 2.5,
flexShrink: 0,
'&:hover': {
bgcolor: 'primary.dark',
},
'&.Mui-disabled': {
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',
cursor: 'default',
},
}}
>
{isWorking ? (
<CircularProgress size={18} color="inherit" thickness={4.5} />
) : (
<SendRoundedIcon sx={{ fontSize: 20 }} />
)}
</IconButton>
</Box>
</Box>
);
}
render() {
const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state;
const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth;
return (
<>
<Drawer
anchor="right"
open={isOpen}
onClose={this.handleClose}
disableScrollLock
transitionDuration={0}
ModalProps={{
disableScrollLock: true,
disableEnforceFocus: true,
}}
sx={(theme) => ({
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 */}
<Box
onPointerDown={this.handleResizeStart}
onMouseDown={this.handleResizeStart}
onTouchStart={this.handleResizeStart}
onDoubleClick={this.handleResizeDoubleClick}
title="Ziehen zum Anpassen der Breite • Doppelklick zum Zurücksetzen"
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: 16,
cursor: 'col-resize',
zIndex: 9999,
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
userSelect: 'none',
'&:hover .drawer-resizer-line, &:active .drawer-resizer-line': {
bgcolor: 'primary.main',
height: 56,
width: 4,
opacity: 1,
boxShadow: `0 0 10px ${BLUE_GLOW}`,
},
}}
>
<Box
className="drawer-resizer-line"
sx={{
width: isResizing ? 4 : 3,
height: isResizing ? 56 : 36,
borderRadius: 2,
bgcolor: isResizing ? 'primary.main' : 'divider',
opacity: isResizing ? 1 : 0.6,
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
boxShadow: isResizing ? `0 0 10px ${BLUE_GLOW}` : 'none',
}}
/>
</Box>
{/* Left: VFS Filesystem Editor Panel (parallel to chat) */}
{isVfsOpen && (
<Box
sx={{
width: { xs: '100%', sm: `${vfsWidth}px` },
minWidth: { sm: 340 },
maxWidth: { sm: 'calc(100vw - 320px)' },
height: '100%',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<VfsEditorPanel
onInsertToChat={this.handleInsertToChat}
onMentionReference={this.handleMentionReference}
/>
</Box>
)}
{/* Vertical Split Resizer between VFS and Chat (0px width so no layout shift or grey bar) */}
{isVfsOpen && (
<Box
onPointerDown={this.handleVfsResizerStart}
onMouseDown={this.handleVfsResizerStart}
sx={{
width: 0,
position: 'relative',
flexShrink: 0,
zIndex: 10,
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
justifyContent: 'center',
cursor: 'col-resize',
touchAction: 'none',
userSelect: 'none',
'&:hover .vfs-resizer-line, &:active .vfs-resizer-line': {
bgcolor: 'primary.main',
opacity: 1,
height: 48,
width: 4,
boxShadow: `0 0 8px ${BLUE_GLOW}`,
},
}}
>
{/* Invisible wide hit area (16px wide, centered on the 1px border) */}
<Box
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: -8,
width: 16,
cursor: 'col-resize',
}}
/>
{/* Visual handle indicator on hover / active */}
<Box
className="vfs-resizer-line"
sx={{
position: 'absolute',
width: 2,
height: 32,
borderRadius: 1,
bgcolor: 'divider',
opacity: 0.5,
transition: 'background-color 0.15s, height 0.15s, width 0.15s, opacity 0.15s, box-shadow 0.15s',
pointerEvents: 'none',
}}
/>
</Box>
)}
{/* Right: Chat Panel */}
<Box
sx={{
width: isVfsOpen ? { xs: '100%', sm: `${drawerWidth}px` } : '100%',
flexGrow: 1,
minWidth: { sm: 320 },
display: isVfsOpen ? { xs: 'none', sm: 'flex' } : 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{this.renderHeader()}
{this.renderBody()}
{this.renderFooter()}
</Box>
</Drawer>
{/* Extraction Preview Dialog */}
<Dialog
open={Boolean(this.state.previewExtraction)}
onClose={this.handleCloseExtractionPreview}
maxWidth="md"
fullWidth
sx={(theme) => ({
zIndex: theme.zIndex.modal + 50,
})}
slotProps={{
paper: {
sx: {
borderRadius: 2.5,
bgcolor: 'background.paper',
backgroundImage: 'none',
},
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionOutlinedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
{this.state.previewExtraction?.label || 'parsed invoice'}
</Typography>
</Box>
<IconButton size="small" onClick={this.handleCloseExtractionPreview} aria-label="Schließen">
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1.5,
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
maxHeight: '60vh',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
{this.state.previewExtraction?.text || ''}
</Box>
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Button
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
onClick={this.handleCopyExtractionText}
color="primary"
variant="outlined"
size="small"
>
Kopieren
</Button>
<Button onClick={this.handleCloseExtractionPreview} color="inherit" size="small">
Schließen
</Button>
</DialogActions>
</Dialog>
</>
);
}
}