claude-desktop/src/lib/components/ChatPanel.svelte
Eddy 71ab5ec830
All checks were successful
Build AppImage / build (push) Successful in 8m18s
feat: Schulungsmodus Datei-Animationen + Permission-Toggle + Chat-Scroll-Fix [appimage]
- AnimatedFileEdit.svelte: neue Komponente fuer animierte Datei-Aenderungen im Praesentation-Fenster
- Schulungsmodus: 5-Stufen-Speed-Regler (Lehrer 10cps bis Data-Modus instant+Glow)
- Schulungsmodus: Live-Catchup-Button, Auto-Weiter nach Slide-Abschluss
- ChatPanel: Permission-Mode-Toggle links vom Textfeld (default/acceptEdits/bypassPermissions)
- ApprovalBar: Floating-Card mit blauem Glow, Buttons umbenannt (Anwenden/Ablehnen)
- MessageList: Scroll-Guard mit scrollend-Event + 700ms-Fallback statt doppeltem rAF
- MessageList: User-Nachrichten scrollen sofort nach unten (requestAnimationFrame + force)
- Message.svelte: MessagePart[]-basiertes Rendering fuer chronologische Reihenfolge
- events.ts: file-change sendet Slide an Praesentation-Fenster wenn offen
- teaching.rs: presentation_send_slide_if_open Command
- claude.rs: set/get_permission_mode Commands mit DB-Persistenz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:13:52 +02:00

2651 lines
70 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, pendingChanges, permissionMode, type Message, type QuickAction, type FileChange, type PermissionMode } from '$lib/stores/app';
import { currentTool, processingPhase } from '$lib/stores/events';
import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import CommandPalette from './CommandPalette.svelte';
import DiffView from './DiffView.svelte';
import FileMention from './FileMention.svelte';
import QuickActions from './QuickActions.svelte';
import MessageList from './MessageList.svelte';
import ConversationBanner from './ConversationBanner.svelte';
import ApprovalBar from './ApprovalBar.svelte';
import { startConversation, stopConversation, conversationActive } from '$lib/voice/conversationEngine';
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
// Props
let { detached = false }: { detached?: boolean } = $props();
// Input-Referenz für Focus-Shortcuts
let inputTextarea: HTMLTextAreaElement;
// Custom Renderer für Code-Blöcke mit Wrapper
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: Tokens.Code): string {
const language = lang || '';
const escapedCode = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<div class="code-block-wrapper" data-lang="${language}"><pre><code class="language-${language}">${escapedCode}</code></pre></div>`;
};
marked.setOptions({ breaks: true, gfm: true, renderer });
// Collapse: Nachrichten mit > X Zeilen werden eingeklappt (rollenabhängig)
const COLLAPSE_LINES_USER = 10;
const COLLAPSE_LINES_ASSISTANT = 25;
let expandedMessages = $state<string[]>([]);
function toggleExpand(msgId: string) {
if (expandedMessages.includes(msgId)) {
expandedMessages = expandedMessages.filter(id => id !== msgId);
} else {
expandedMessages = [...expandedMessages, msgId];
}
}
function shouldCollapse(content: string, role: string = 'assistant'): boolean {
const limit = role === 'user' ? COLLAPSE_LINES_USER : COLLAPSE_LINES_ASSISTANT;
return content.split('\n').length > limit;
}
function getCollapseLimit(role: string): number {
return role === 'user' ? COLLAPSE_LINES_USER : COLLAPSE_LINES_ASSISTANT;
}
function renderMarkdown(text: string): string {
try {
// Thinking-Blöcke erkennen und in <details> packen
const processed = collapseThinkingBlocks(text);
return marked.parse(processed) as string;
} catch {
return text;
}
}
/**
* Erkennt "Denk-Blöcke" im Text und zeigt sie als kompakte Inline-Elemente.
* Zwei Quellen:
* 1. SDK Extended-Thinking (kommt als <div class="thinking-inline"> von Bridge)
* 2. Text-basierte Patterns (Lass mich analysieren..., Ich schaue mir...)
*/
function collapseThinkingBlocks(text: string): string {
// Bereits von Bridge als inline-div geliefert? Nicht nochmal wrappen.
if (text.includes('<div class="thinking-inline">')) return text;
// Legacy: Falls noch alte <details> vorhanden, konvertieren
if (text.includes('<details class="thinking-block">')) {
return text.replace(
/<details class="thinking-block">.*?<div class="thinking-content">([\s\S]*?)<\/div><\/details>/g,
(_match, content) => `<div class="thinking-inline"><span class="thinking-label">\u{1F4AD}</span><span class="thinking-text">${content}</span></div>`
);
}
// Pattern: Text beginnt mit Analyse/Überlegungs-Block, dann kommt die Antwort
const thinkingPatterns = [
/^((?:(?:Lass mich|Let me|Ich (?:schaue|prüfe|analysiere|untersuche|überleg|werde)|OK,? (?:lass|ich)|Gut,? (?:lass|ich)|Hmm|Also,? |Zunächst|Zuerst|Schauen wir|Jetzt (?:schaue|prüfe|check)).*?\n(?:.*\n)*?))((?:\n(?:#{1,3} |(?:\*\*|Die |Das |Hier |Zusammen|Fertig|Erledigt|Done|✅|---)).*[\s\S]*))/m,
];
for (const pattern of thinkingPatterns) {
const match = text.match(pattern);
if (match && match[1] && match[1].split('\n').length > 5) {
const thinkingPart = match[1].trim().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const answerPart = match[2].trim();
return `<div class="thinking-inline"><span class="thinking-label">\u{1F4AD}</span><span class="thinking-text">${thinkingPart}</span></div>\n\n${answerPart}`;
}
}
return text;
}
// Tool-Aktivitätsanzeige Hilfsfunktionen
function getToolIcon(tool: string): string {
const icons: Record<string, string> = {
'Read': '\u{1F4D6}', 'Write': '\u{270F}\u{FE0F}', 'Edit': '\u{270F}\u{FE0F}',
'Bash': '\u{26A1}', 'Grep': '\u{1F50D}', 'Glob': '\u{1F50D}',
'Task': '\u{1F916}', 'Agent': '\u{1F916}',
};
return icons[tool] || '\u{2699}\u{FE0F}';
}
function getToolLabel(tool: string, input: Record<string, unknown> | null): string {
if (!input) return `${tool}...`;
switch(tool) {
case 'Read': return `Liest ${(input.file_path as string)?.split('/').pop() || 'Datei'}...`;
case 'Write': return `Schreibt ${(input.file_path as string)?.split('/').pop() || 'Datei'}...`;
case 'Edit': return `Bearbeitet ${(input.file_path as string)?.split('/').pop() || 'Datei'}...`;
case 'Bash': return `Führt aus: ${((input.command as string) || '').substring(0, 40)}...`;
case 'Grep': return `Sucht: ${(input.pattern as string) || ''}...`;
case 'Glob': return `Sucht Dateien: ${(input.pattern as string) || ''}...`;
case 'Task': case 'Agent': return `Delegiert: ${((input.description as string) || (input.prompt as string) || '').substring(0, 40)}...`;
default: return `${tool}...`;
}
}
// Phasen-basierte Status-Anzeige
function getPhaseIcon(phase: string): string {
switch(phase) {
case 'thinking': return '\u{1F9E0}'; // 🧠
case 'streaming': return '\u{270D}\u{FE0F}'; // ✍️
case 'tool-use': return '\u{1F527}'; // 🔧
case 'subagent': return '\u{1F916}'; // 🤖
default: return '\u{2699}\u{FE0F}'; // ⚙️
}
}
function getPhaseLabel(phase: string): string {
switch(phase) {
case 'thinking': return 'Denkt nach';
case 'streaming': return 'Schreibt Antwort';
case 'tool-use': return 'Arbeitet';
case 'subagent': return 'Subagent aktiv';
default: return 'Verarbeitet';
}
}
// Svelte Action: Copy-Buttons zu Code-Blöcken hinzufügen
function addCopyButtons(node: HTMLElement) {
function processCodeBlocks() {
const wrappers = node.querySelectorAll('.code-block-wrapper:not([data-copy-added])');
wrappers.forEach((wrapper) => {
wrapper.setAttribute('data-copy-added', 'true');
const lang = wrapper.getAttribute('data-lang') || '';
const codeEl = wrapper.querySelector('code');
const codeText = codeEl?.textContent || '';
// Header mit Sprache und Copy-Button erstellen
const header = document.createElement('div');
header.className = 'code-header';
if (lang) {
const langSpan = document.createElement('span');
langSpan.className = 'code-lang';
langSpan.textContent = lang;
header.appendChild(langSpan);
}
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.title = 'Code kopieren';
copyBtn.innerHTML = '📋';
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeText);
copyBtn.innerHTML = '<span class="copied">✓</span>';
setTimeout(() => (copyBtn.innerHTML = '📋'), 2000);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
};
header.appendChild(copyBtn);
wrapper.insertBefore(header, wrapper.firstChild);
});
}
// Initial verarbeiten
processCodeBlocks();
// MutationObserver für dynamische Inhalte (Streaming)
const observer = new MutationObserver(processCodeBlocks);
observer.observe(node, { childList: true, subtree: true });
return {
destroy() {
observer.disconnect();
}
};
}
let messagesContainer: HTMLDivElement;
// Edit-Modus State
let editingMessageId: string | null = $state(null);
let editingContent: string = $state('');
// Auto-Compacting State
let lastMessageCount = 0;
let compactingWarningShown = false;
let showCompactingDialog = $state(false);
let estimatedTokens = $state(0);
const TOKEN_WARNING_THRESHOLD = 40000; // ~40k Token = Warnung zeigen
const KEEP_LAST_MESSAGES = 30;
// Voice-Interface State
let isRecording = $state(false);
let audioLevel = $state(0);
let liveTranscript = $state('');
let mediaRecorder: MediaRecorder | null = null;
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let audioChunks: Blob[] = [];
let levelAnimationFrame: number | null = null;
// Quick-Actions Palette (Ctrl+K)
let showQuickActions = $state(false);
// File-Drop State
let isDragOver = $state(false);
let dragCounter = 0; // Verhindert Flackern bei verschachtelten Elementen
function handleDragEnter(e: DragEvent) {
e.preventDefault();
dragCounter++;
if (e.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
isDragOver = false;
dragCounter = 0;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
dragCounter = 0;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const fileContents: string[] = [];
for (const file of Array.from(files)) {
try {
// Bilder: als Base64 einbetten (Claude kann Bilder analysieren)
if (file.type.startsWith('image/')) {
const base64 = await fileToBase64(file);
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}, ${file.type})\n\n![${file.name}](${base64})`);
continue;
}
// Textdateien: Inhalt lesen
if (file.size > 500_000) {
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}) — Datei zu groß für direkten Chat (max 500KB). Bitte über Dateipfad referenzieren.`);
continue;
}
const text = await file.text();
const ext = file.name.split('.').pop() || '';
const lang = extToLang(ext);
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)})\n\`\`\`${lang}\n${text}\n\`\`\``);
} catch (err) {
fileContents.push(`📎 **${file.name}** — Fehler beim Lesen: ${err}`);
}
}
if (fileContents.length > 0) {
const plural = files.length > 1 ? `${files.length} Dateien` : 'Datei';
const prefix = `Ich habe dir ${plural} in den Chat gezogen. Analysiere bitte:\n\n`;
$currentInput = prefix + fileContents.join('\n\n---\n\n');
inputTextarea?.focus();
}
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function extToLang(ext: string): string {
const map: Record<string, string> = {
ts: 'typescript', js: 'javascript', py: 'python', rs: 'rust',
svelte: 'svelte', html: 'html', css: 'css', json: 'json',
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown',
sh: 'bash', sql: 'sql', php: 'php', nix: 'nix', xml: 'xml',
vue: 'vue', tsx: 'tsx', jsx: 'jsx', go: 'go', java: 'java',
};
return map[ext.toLowerCase()] || ext;
}
// Slash-Command Autocomplete State
let showCommandPalette = $state(false);
let commandQuery = $state('');
let commandPaletteRef: CommandPalette | undefined = $state(undefined);
// @-Mention Autocomplete State
let showFileMention = $state(false);
let mentionQuery = $state('');
let fileMentionRef: FileMention | undefined = $state(undefined);
// Slash-Command und @-Mention Erkennung im Input
$effect(() => {
const text = $currentInput;
if (text.startsWith('/')) {
showCommandPalette = true;
commandQuery = text.slice(1);
showFileMention = false;
} else {
showCommandPalette = false;
commandQuery = '';
}
// @-Mention: Suche das letzte @ im Text
const atMatch = text.match(/@([\w.\-/]+)$/);
if (atMatch && atMatch[1].length > 0) {
showFileMention = true;
mentionQuery = atMatch[1];
} else {
showFileMention = false;
mentionQuery = '';
}
});
// @-Mention: Datei ausgewaehlt → @query durch @dateiname ersetzen
function handleFileSelect(file: { name: string; path: string; full_path: string }) {
// Ersetze das letzte @query durch @dateiname
const text = $currentInput;
const atIdx = text.lastIndexOf('@');
if (atIdx >= 0) {
$currentInput = text.substring(0, atIdx) + `@${file.path} `;
}
showFileMention = false;
}
// Command auswählen: Text ersetzen
function handleCommandSelect(cmd: { name: string; description: string; category: string }) {
if (!cmd.name) {
// Escape gedrückt — Palette schliessen, Text behalten
showCommandPalette = false;
return;
}
$currentInput = '/' + cmd.name + ' ';
showCommandPalette = false;
// Focus zurück aufs Input
inputTextarea?.focus();
}
// VAD (Voice Activity Detection) — automatisches Stoppen nach Sprechpause
const VAD_SILENCE_THRESHOLD = 15; // Pegel unter dem als Stille gilt
const VAD_SILENCE_DURATION = 1500; // ms Stille vor Auto-Stopp
let silenceStartTime: number | null = null;
let vadEnabled = $state(true); // VAD ein/aus
async function scrollToBottom() {
await tick();
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
$effect(() => {
if ($messages.length) scrollToBottom();
});
// Bei Session-Wechsel: Compacting-Flag zurücksetzen
$effect(() => {
if ($currentSessionId) {
compactingWarningShown = false;
showCompactingDialog = false;
}
});
// Token-Schätzung: ~4 Zeichen pro Token
function estimateTokensForMessages(msgs: Message[]): number {
return msgs.reduce((total, msg) => total + Math.ceil(msg.content.length / 4), 0);
}
// Nachricht in DB speichern
async function saveMessageToDb(msg: Message) {
const sessionId = get(currentSessionId);
if (!sessionId) return;
try {
const dbMsg = messageToDb(msg, sessionId);
await invoke('save_message', { message: dbMsg });
} catch (err) {
console.error('Fehler beim Speichern der Nachricht:', err);
}
}
// Neue Nachrichten automatisch speichern + Token-Warnung
const unsubscribe = messages.subscribe(async (msgs) => {
if (msgs.length > lastMessageCount && lastMessageCount > 0) {
// Neue Nachricht(en) hinzugefügt
const newMessages = msgs.slice(lastMessageCount);
for (const msg of newMessages) {
// Nur speichern wenn Nachricht Content hat (nicht die leere Streaming-Nachricht)
if (msg.content && msg.content.trim()) {
await saveMessageToDb(msg);
}
}
}
// Token-Schätzung aktualisieren
estimatedTokens = estimateTokensForMessages(msgs);
// Warnung zeigen wenn Token-Schwelle überschritten (einmalig pro Session)
if (!compactingWarningShown && estimatedTokens > TOKEN_WARNING_THRESHOLD && !$isProcessing) {
compactingWarningShown = true;
showCompactingDialog = true;
}
lastMessageCount = msgs.length;
});
async function performCompacting() {
const sessionId = get(currentSessionId);
if (!sessionId) return;
showCompactingDialog = false;
try {
// Zuerst: Kritischen Kontext extrahieren und archivieren
const currentMessages = get(messages);
const messagesJson = JSON.stringify(currentMessages.map(m => ({
role: m.role,
content: m.content
})));
try {
const extracted = await invoke('extract_context_before_compacting', {
sessionId,
messagesJson
});
console.log('📦 Kontext extrahiert vor Compacting:', extracted);
} catch (extractErr) {
console.warn('Context-Extraction fehlgeschlagen (nicht kritisch):', extractErr);
}
// Dann: Compacting durchführen
const compacted: number = await invoke('compact_session', {
sessionId,
keepLast: KEEP_LAST_MESSAGES
});
if (compacted > 0) {
addMessage('system', `📦 Compacting: ${compacted} ältere Nachrichten wurden zusammengefasst. Die letzten ${KEEP_LAST_MESSAGES} bleiben erhalten. Kritischer Kontext wurde archiviert.`);
}
} catch (err) {
console.error('Compacting fehlgeschlagen:', err);
addMessage('system', `⚠️ Compacting fehlgeschlagen: ${err}`);
}
}
function dismissCompactingDialog() {
showCompactingDialog = false;
// Warnung für diese Session nicht erneut zeigen
}
// ============ Voice Interface ============
// getUserMedia mit Timeout — WebKitGTK hängt manchmal endlos statt zu fehlern
function getUserMediaWithTimeout(constraints: MediaStreamConstraints, timeoutMs = 5000): Promise<MediaStream> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`getUserMedia Timeout nach ${timeoutMs}ms — GStreamer-Pipeline hängt`));
}, timeoutMs);
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
clearTimeout(timer);
resolve(stream);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
async function startRecording() {
try {
let stream: MediaStream;
// Prüfe zuerst ob mediaDevices überhaupt verfügbar ist
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('MediaDevices API nicht verfügbar — WebKitGTK braucht GStreamer + PipeWire');
}
try {
stream = await getUserMediaWithTimeout({ audio: true });
} catch (initialErr) {
// WebKitGTK wirft verschiedene Fehler: OverconstrainedError, NotFoundError,
// TypeError("Invalid constraint"), Timeout. Bei JEDEM Fehler Fallback versuchen.
console.warn('getUserMedia({audio:true}) fehlgeschlagen, versuche Fallback:', initialErr);
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInput = devices.find(d => d.kind === 'audioinput');
if (audioInput) {
console.log('Audio-Device gefunden:', audioInput.label || audioInput.deviceId);
stream = await getUserMediaWithTimeout({
audio: { deviceId: { exact: audioInput.deviceId } }
});
} else {
// Letzter Versuch: komplett ohne Constraints
stream = await getUserMediaWithTimeout({ audio: {} });
}
} catch (fallbackErr) {
throw new Error(
'Mikrofon-Zugriff fehlgeschlagen. Mögliche Ursachen:\n' +
'• GStreamer-Plugins fehlen (gst-plugin-pipewire)\n' +
'• PipeWire läuft nicht\n' +
'• App wurde nicht über den Nix-Wrapper gestartet\n' +
`Original: ${initialErr instanceof Error ? initialErr.message : initialErr}`
);
}
}
// Audio-Analyse für Pegel-Anzeige
audioContext = new AudioContext();
analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
analyser.fftSize = 256;
// Pegel-Animation starten
updateAudioLevel();
// MediaRecorder für Aufnahme
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
// Aufnahme beendet — Audio an Whisper senden
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
await transcribeAudio(audioBlob);
// Stream stoppen
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start(100); // Chunks alle 100ms
isRecording = true;
liveTranscript = '';
silenceStartTime = null; // VAD-Timer zurücksetzen
console.log('🎤 Aufnahme gestartet' + (vadEnabled ? ' (VAD aktiv)' : ''));
} catch (err) {
console.error('Mikrofon-Zugriff fehlgeschlagen:', err);
const hint = (err instanceof DOMException && err.name === 'OverconstrainedError')
? ' WebKitGTK (Tauri/Linux) unterstützt ggf. kein Audio-Capture — prüfe PipeWire/GStreamer-Plugins.'
: '';
addMessage('system', `⚠️ Mikrofon-Zugriff fehlgeschlagen: ${err instanceof Error ? err.message : err}${hint}`);
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
// Pegel-Animation stoppen
if (levelAnimationFrame) {
cancelAnimationFrame(levelAnimationFrame);
levelAnimationFrame = null;
}
// Audio-Context schließen
if (audioContext) {
audioContext.close();
audioContext = null;
}
isRecording = false;
audioLevel = 0;
console.log('🎤 Aufnahme gestoppt');
}
function updateAudioLevel() {
if (!analyser || !isRecording) return;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(dataArray);
// Durchschnittspegel berechnen
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
audioLevel = Math.min(100, average * 1.5); // Normalisieren auf 0-100
// VAD: Stille erkennen und nach Pause automatisch stoppen
if (vadEnabled && audioChunks.length > 0) {
if (audioLevel < VAD_SILENCE_THRESHOLD) {
// Stille beginnt oder dauert an
if (silenceStartTime === null) {
silenceStartTime = Date.now();
} else if (Date.now() - silenceStartTime > VAD_SILENCE_DURATION) {
// Lange genug still — Aufnahme automatisch stoppen
console.log('🔇 VAD: Stille erkannt, stoppe Aufnahme');
stopRecording();
return;
}
} else {
// Sprache erkannt — Stille-Timer zurücksetzen
silenceStartTime = null;
}
}
levelAnimationFrame = requestAnimationFrame(updateAudioLevel);
}
async function transcribeAudio(audioBlob: Blob) {
liveTranscript = 'Transkribiere...';
try {
// Audio als Base64 für Tauri-Command
const arrayBuffer = await audioBlob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
// An Backend senden für Whisper-Transkription
const transcript: string = await invoke('transcribe_audio', {
audioBase64: base64,
format: 'webm'
});
if (transcript && transcript.trim()) {
// Transkript in Input-Feld einfügen
$currentInput = ($currentInput + ' ' + transcript).trim();
liveTranscript = '';
console.log('📝 Transkript:', transcript);
} else {
liveTranscript = '';
}
} catch (err) {
console.error('Transkription fehlgeschlagen:', err);
liveTranscript = `Fehler: ${err}`;
// Nach 3s ausblenden
setTimeout(() => { liveTranscript = ''; }, 3000);
}
}
function toggleRecording() {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
}
// Mic-Button: zwei Modi
// - Kurzer Klick (<450ms loslassen) = Diktat (alt: Audio in Textbox)
// - Lang halten (>=450ms) = Konversationsmodus (TTS, Barge-In)
// Wenn Konversation bereits aktiv ist: jeder Klick beendet sie.
const LONG_PRESS_MS = 450;
let micPressTimer: ReturnType<typeof setTimeout> | null = null;
let micWasLongPress = false;
function onMicPointerDown(_e: PointerEvent) {
// Wenn Konversation laeuft → erster Klick beendet
if (get(conversationActive)) {
stopConversation();
return;
}
micWasLongPress = false;
micPressTimer = setTimeout(() => {
micWasLongPress = true;
micPressTimer = null;
// Falls gerade Diktat laeuft, vorher stoppen
if (isRecording) stopRecording();
startConversation().catch((err) => console.error('Konversation-Start fehlgeschlagen:', err));
}, LONG_PRESS_MS);
}
function onMicPointerUp(_e: PointerEvent) {
if (micPressTimer) {
clearTimeout(micPressTimer);
micPressTimer = null;
}
// Wenn nicht zum Long-Press eskaliert → Diktat
if (!micWasLongPress && !get(conversationActive)) {
toggleRecording();
}
micWasLongPress = false;
}
onDestroy(() => {
unsubscribe();
// Voice-Aufnahme stoppen falls aktiv
if (isRecording) {
stopRecording();
}
});
// Quick-Action ausführen (Callback von QuickActions-Komponente)
function handleQuickAction(action: QuickAction) {
if (action.command) {
// Als Nachricht an Claude senden
$currentInput = action.command;
sendMessage();
} else if (action.id === 'settings') {
// Navigation: Settings-Tab aktivieren (Event an Hauptfenster)
emit('navigate-tab', { panel: 'right', tab: 'settings' });
} else if (action.id === 'monitor') {
emit('navigate-tab', { panel: 'middle', tab: 'monitor' });
} else if (action.id === 'voice-check') {
// Ergebnis als System-Message
invoke('check_voice_availability').then((result) => {
addMessage('system', `🎤 Voice-Status: ${JSON.stringify(result, null, 2)}`);
}).catch((err) => {
addMessage('system', `⚠️ Voice-Check fehlgeschlagen: ${err}`);
});
}
}
// Globale Keyboard Shortcuts
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl+K: Quick-Actions Palette öffnen/schliessen
if (event.ctrlKey && event.key === 'k') {
event.preventDefault();
showQuickActions = !showQuickActions;
return;
}
// Ctrl+Shift+K: Input leeren und Focus
if (event.ctrlKey && event.shiftKey && event.key === 'K') {
event.preventDefault();
$currentInput = '';
inputTextarea?.focus();
return;
}
}
onMount(async () => {
window.addEventListener('keydown', handleGlobalKeydown);
// Global Hotkey: Super+C fokussiert das Input-Feld
const unlistenFocus = await listen('focus-chat-input', () => {
inputTextarea?.focus();
});
// Clipboard-Watch Events: Vorschlag im Chat anzeigen
const unlistenClipboard = await listen<{ content: string; content_type: string; suggestion: string }>('clipboard-changed', (event) => {
const { content, content_type, suggestion } = event.payload;
const preview = content.length > 200 ? content.substring(0, 200) + '...' : content;
addMessage('system', `📋 Clipboard (${content_type}): ${suggestion}\n\`\`\`\n${preview}\n\`\`\``);
});
// Legacy-Queue Subscriber (Sicherheitsnetz — Hauptlogik ist jetzt in der Bridge)
const unsubProcessing = isProcessing.subscribe(() => {});
// Phase 11: Permission-Modus aus Settings wiederherstellen, damit der
// Toggle-Knopf links vom Textfeld den letzten Stand zeigt und die
// Bridge ihn beim ersten send_message direkt verwendet.
try {
const savedMode = await invoke<string>('get_permission_mode');
if (savedMode === 'default' || savedMode === 'acceptEdits' || savedMode === 'bypassPermissions') {
permissionMode.set(savedMode);
if (savedMode !== 'default') {
invoke('set_permission_mode', { mode: savedMode }).catch(() => {});
}
}
} catch (e) {
console.debug('Permission-Modus laden fehlgeschlagen:', e);
}
return () => {
window.removeEventListener('keydown', handleGlobalKeydown);
unsubProcessing();
unlistenFocus();
unlistenClipboard();
};
});
function cancelQueued() {
// Legacy: Queue-Cancel (Bridge hat jetzt eigene Pending-Queue)
messageQueue.set([]);
$queuedMessage = null;
}
// Accept/Reject Datei-Aenderungen
async function acceptChange(toolId: string) {
try {
await invoke('accept_change', { toolId });
pendingChanges.update((changes) => changes.filter((c) => c.toolId !== toolId));
console.log('✅ Aenderung akzeptiert:', toolId);
} catch (err) {
console.error('Accept fehlgeschlagen:', err);
}
}
async function rejectChange(toolId: string) {
try {
const result = await invoke<string>('reject_change', { toolId });
pendingChanges.update((changes) => changes.filter((c) => c.toolId !== toolId));
addMessage('system', `↩️ ${result}`);
console.log('↩️ Aenderung abgelehnt:', toolId);
} catch (err) {
console.error('Reject fehlgeschlagen:', err);
addMessage('system', `Fehler beim Zuruecksetzen: ${err}`);
}
}
async function sendMessage() {
const text = $currentInput.trim();
if (!text) return;
// Input sofort leeren — Text ist in `text` gesichert.
// Muss VOR dispatchMessage() passieren, damit die Textbox auch bei
// Fehlern (Session-Erstellung, DB-Save) zuverlaessig geleert wird.
// Svelte 5 + writable-Store + bind:value aktualisiert den DOM-Wert
// erst nach dem naechsten tick — DOM hart leeren damit der User
// sofort sieht dass die Nachricht weg ist und kein Race entsteht.
$currentInput = '';
if (inputTextarea) {
inputTextarea.value = '';
inputTextarea.style.height = 'auto'; // auto-resize zuruecksetzen
}
await tick();
// Nachricht IMMER sofort senden — auch während Claude arbeitet.
// Die Bridge puffert die Nachricht intern und verarbeitet sie
// automatisch nach dem aktuellen Turn (wie in Claude Code/VS Code Extension).
await dispatchMessage(text);
}
// Den eigentlichen Send-Flow — sendet immer sofort an die Bridge.
// Wenn eine query() laeuft, puffert die Bridge die Nachricht intern.
async function dispatchMessage(text: string) {
// Auto-Session erstellen falls keine aktiv
let sessionId = get(currentSessionId);
if (!sessionId) {
try {
const title = `Session ${new Date().toLocaleDateString('de-DE')} ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const newSession = await invoke<{ id: string }>('create_session', { title });
sessionId = newSession.id;
$currentSessionId = sessionId;
console.log('📂 Auto-Session erstellt:', title);
// SessionList benachrichtigen
await emit('session-created', { id: sessionId });
} catch (err) {
console.error('Session-Erstellung fehlgeschlagen:', err);
addMessage('system', `Fehler: Konnte keine Session erstellen - ${err}`);
return;
}
}
// @-Mentions aufloesen: @pfad/datei.ts durch Dateiinhalt ersetzen
let resolvedText = text;
const mentionPattern = /@([\w.\-/]+(?:#\d+(?:-\d+)?)?)/g;
const mentions = [...text.matchAll(mentionPattern)];
for (const match of mentions) {
const ref = match[1];
const [filePath, lineRange] = ref.split('#');
try {
const fullPath = filePath.startsWith('/') ? filePath : await invoke<string>('resolve_file_path', { relativePath: filePath });
const content = await invoke<string>('read_file_content', { filePath: fullPath, lineRange: lineRange || null });
const ext = filePath.split('.').pop() || '';
resolvedText = resolvedText.replace(
match[0],
`\n\`\`\`${ext} (${filePath})\n${content}\n\`\`\`\n`
);
} catch {
// Datei nicht gefunden — @-Mention unverändert lassen
}
}
// User-Nachricht sofort im Chat anzeigen + in DB speichern
const msgId = crypto.randomUUID();
const msg: Message = {
id: msgId,
role: 'user',
content: text,
timestamp: new Date(),
};
messages.update((msgs) => [...msgs, msg]);
await saveMessageToDb(msg);
// isProcessing nur setzen wenn nicht schon aktiv
// (bei gepufferten Nachrichten laeuft Claude ja schon)
if (!$isProcessing) {
$isProcessing = true;
}
try {
// Aufgeloesten Text an Claude senden (mit eingebetteten Datei-Inhalten)
await invoke('send_message', { message: resolvedText });
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
// Nur auf false setzen wenn keine weiteren Nachrichten pending
$isProcessing = false;
}
}
function handleKeydown(event: KeyboardEvent) {
// CommandPalette hat Vorrang bei Tastatur-Events
if (showCommandPalette && commandPaletteRef) {
const handled = commandPaletteRef.handleKey(event);
if (handled) return;
}
// FileMention hat Vorrang bei @-Autocomplete
if (showFileMention && fileMentionRef) {
const handled = fileMentionRef.handleKey(event);
if (handled) return;
}
// Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text)
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
sendMessage();
return;
}
// Enter (ohne Shift): Senden
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// Streaming-Message-ID fuer MessageList (ohne findLast — kompatibel)
const streamingMessageId = $derived.by(() => {
if (!$isProcessing) return null;
for (let i = $messages.length - 1; i >= 0; i--) {
const m = $messages[i];
if (m.role === 'assistant' && !m.content) return m.id;
}
return null;
});
// Wrapper fuer MessageList: ID → Message bzw. Index aufloesen
function handleEditById(id: string) {
const m = $messages.find(x => x.id === id);
if (m) startEdit(m);
}
function handleRegenerateById(id: string) {
const idx = $messages.findIndex(x => x.id === id);
if (idx >= 0) regenerateResponse(idx);
}
function handleRewindById(id: string) {
// Rewind ist heute noch nicht ueber UI verdrahtet — Platzhalter
console.log('Rewind angefordert fuer Message', id);
}
// Edit-Funktionen
function startEdit(message: Message) {
editingMessageId = message.id;
editingContent = message.content;
}
function cancelEdit() {
editingMessageId = null;
editingContent = '';
}
async function confirmEdit() {
if (!editingMessageId || !editingContent.trim()) return;
const msgIndex = $messages.findIndex((m) => m.id === editingMessageId);
if (msgIndex === -1) return;
const updatedMessage = {
...$messages[msgIndex],
content: editingContent.trim(),
timestamp: new Date()
};
// Nachricht aktualisieren und alle folgenden entfernen
// (da sie auf der alten Nachricht basieren)
messages.update((msgs) => {
const newMsgs = [...msgs.slice(0, msgIndex), updatedMessage];
return newMsgs;
});
// Nachricht in DB aktualisieren
await saveMessageToDb(updatedMessage);
// Edit-Modus beenden
const newContent = editingContent.trim();
editingMessageId = null;
editingContent = '';
// Nachricht neu an Claude senden
$isProcessing = true;
try {
await invoke('send_message', { message: newContent });
} catch (err) {
console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
confirmEdit();
} else if (event.key === 'Escape') {
cancelEdit();
}
}
// Regenerate: Antwort neu generieren
async function regenerateResponse(assistantMsgIndex: number) {
if ($isProcessing) return;
// Vorherige User-Nachricht finden
let userMsg: Message | null = null;
for (let i = assistantMsgIndex - 1; i >= 0; i--) {
if ($messages[i].role === 'user') {
userMsg = $messages[i];
break;
}
}
if (!userMsg) {
console.error('Keine User-Nachricht zum Regenerieren gefunden');
return;
}
// Alle Nachrichten ab der Assistant-Nachricht entfernen
messages.update((msgs) => msgs.slice(0, assistantMsgIndex));
// User-Nachricht erneut senden
$isProcessing = true;
try {
await invoke('send_message', { message: userMsg.content });
} catch (err) {
console.error('Fehler beim Regenerieren:', err);
addMessage('system', `Fehler: ${err}`);
$isProcessing = false;
}
}
// Prüfen ob eine Nachricht die letzte Assistant-Nachricht ist
function isLastAssistantMessage(index: number): boolean {
// Finde die letzte Assistant-Nachricht
for (let i = $messages.length - 1; i >= 0; i--) {
if ($messages[i].role === 'assistant' && $messages[i].content) {
return i === index;
}
}
return false;
}
// "Das merken" - Speichern in Wissensbasis
let rememberDialogOpen = $state(false);
let rememberContent = $state('');
let rememberEntry = $state({
category: 'pattern',
title: '',
tags: '',
priority: 2,
});
let rememberSaving = $state(false);
let rememberError = $state('');
const knowledgeCategories = [
{ id: 'dolibarr', label: 'Dolibarr' },
{ id: 'fints', label: 'FinTS/Banking' },
{ id: 'pattern', label: 'Pattern/Code' },
{ id: 'projekt', label: 'Projekt' },
{ id: 'setup', label: 'Setup/Config' },
{ id: 'ids', label: 'IDs/Referenzen' },
];
let copyFeedback = $state<string | null>(null);
async function copyMessage(message: Message) {
try {
await navigator.clipboard.writeText(message.content);
copyFeedback = message.id;
setTimeout(() => {
if (copyFeedback === message.id) copyFeedback = null;
}, 1500);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
}
function openRememberDialog(message: Message) {
rememberContent = message.content;
rememberEntry = {
category: 'pattern',
title: '',
tags: '',
priority: 2,
};
rememberError = '';
rememberDialogOpen = true;
}
function closeRememberDialog() {
rememberDialogOpen = false;
rememberContent = '';
}
async function saveToKnowledge() {
if (!rememberEntry.title.trim() || !rememberContent.trim()) {
rememberError = 'Titel und Inhalt sind erforderlich';
return;
}
rememberSaving = true;
rememberError = '';
try {
await invoke('save_knowledge', {
entry: {
category: rememberEntry.category,
title: rememberEntry.title.trim(),
content: rememberContent.trim(),
tags: rememberEntry.tags.trim() || null,
priority: rememberEntry.priority,
status: 'active',
source: 'chat',
related_ids: null,
}
});
closeRememberDialog();
} catch (err) {
rememberError = `Fehler: ${err}`;
} finally {
rememberSaving = false;
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="chat-panel" class:drag-over={isDragOver}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}>
{#if isDragOver}
<div class="drop-overlay">
<div class="drop-icon">📎</div>
<p>Dateien hier ablegen</p>
<p class="drop-hint">Code, Text, Bilder — Claude analysiert alles</p>
</div>
{/if}
<!-- Konversations-Banner: nur sichtbar wenn Voice-Konversation laeuft -->
<ConversationBanner />
<!-- Phase 9: Lokaler Header entfernt — Brand+Stats sind in der globalen
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
<div class="chat-toolbar">
{#if !detached}
<button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">&#x29C9;</button>
{:else}
<button class="tool-btn" onclick={() => invoke('chat_window_close')} title="Zurück ins Hauptfenster">&#x29C7;</button>
{/if}
</div>
<div class="chat-messages-wrap" bind:this={messagesContainer}>
<MessageList
{streamingMessageId}
onEdit={handleEditById}
onRegenerate={handleRegenerateById}
onRemember={openRememberDialog}
onRewind={handleRewindById}
/>
</div>
<!-- Edit-Mode Inline-Editor (legacy, wird nur aktiv wenn editingMessageId gesetzt) -->
{#if editingMessageId}
<div class="edit-overlay">
<textarea
class="edit-textarea"
bind:value={editingContent}
onkeydown={handleEditKeydown}
rows="3"
placeholder="Nachricht bearbeiten..."
></textarea>
<div class="edit-actions">
<button class="edit-btn cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn confirm" onclick={confirmEdit} disabled={!editingContent.trim()}>
Speichern &amp; Senden
</button>
</div>
</div>
{/if}
<!-- Sticky Approval-Bar (Phase 9.1): bleibt sichtbar wenn der Chat scrollt,
der User verliert offene Datei-Aenderungen nicht aus den Augen. Die
eigentliche Diff-Vorschau bleibt inline in der Tool-Karte. -->
<ApprovalBar />
<div class="chat-input">
<CommandPalette
bind:this={commandPaletteRef}
query={commandQuery}
visible={showCommandPalette}
onSelect={handleCommandSelect}
/>
<FileMention
bind:this={fileMentionRef}
query={mentionQuery}
visible={showFileMention}
onSelect={handleFileSelect}
/>
{#if $agentMode && $agentMode !== 'solo'}
<div class="mode-indicator mode-{$agentMode}">
<span class="mode-icon">
{#if $agentMode === 'handlanger'}👷
{:else if $agentMode === 'experten'}🎓
{:else if $agentMode === 'auto'}🤖
{/if}
</span>
<span class="mode-label">
{#if $agentMode === 'handlanger'}Handlanger-Modus
{:else if $agentMode === 'experten'}Experten-Modus
{:else if $agentMode === 'auto'}Auto-Modus
{/if}
</span>
{#if $processingPhase !== 'idle'}
<span class="mode-phase">
{#if $processingPhase === 'thinking'}denkt nach...
{:else if $processingPhase === 'streaming'}schreibt...
{:else if $processingPhase === 'tool-use'}nutzt Tool...
{:else if $processingPhase === 'subagent'}delegiert...
{/if}
</span>
{/if}
</div>
{/if}
{#if liveTranscript}
<div class="live-transcript">
<span class="transcript-icon">🎤</span>
<span class="transcript-text">{liveTranscript}</span>
</div>
{/if}
<div class="input-prefix">
<button
class="perm-toggle"
class:active={$permissionMode !== 'default'}
class:yolo={$permissionMode === 'bypassPermissions'}
onclick={() => {
const next: PermissionMode =
$permissionMode === 'default' ? 'acceptEdits'
: $permissionMode === 'acceptEdits' ? 'bypassPermissions'
: 'default';
permissionMode.set(next);
invoke('set_permission_mode', { mode: next }).catch((e) => console.warn('set_permission_mode:', e));
}}
title={$permissionMode === 'default'
? 'Approval-Modus: Nachfragen vor jedem Edit/Bash. Klick: Edits automatisch.'
: $permissionMode === 'acceptEdits'
? 'Approval-Modus: Edits automatisch, Bash fragt nach. Klick: alles automatisch (Yolo).'
: 'Yolo: alles laeuft ohne Nachfrage. Klick: Approval-Modus aus.'}
>
{#if $permissionMode === 'default'}
🔒
{:else if $permissionMode === 'acceptEdits'}
🔓
{:else}
{/if}
</button>
</div>
<textarea
bind:this={inputTextarea}
bind:value={$currentInput}
onkeydown={handleKeydown}
placeholder={$isProcessing ? 'Weiter tippen — wird nach aktuellem Turn verarbeitet...' : 'Nachricht eingeben... (Ctrl+K = Quick-Actions, Ctrl+Enter = Senden)'}
disabled={isRecording}
rows="3"
></textarea>
<div class="input-buttons">
<button
class="mic-button"
class:recording={isRecording}
class:conversation={$conversationActive}
onpointerdown={onMicPointerDown}
onpointerup={onMicPointerUp}
onpointercancel={onMicPointerUp}
onpointerleave={onMicPointerUp}
title={$conversationActive
? 'Konversation aktiv (Esc oder Klick = beenden)'
: isRecording
? 'Aufnahme stoppen'
: 'Klick: Diktat ins Textfeld · Lang drücken: Konversationsmodus'}
>
{#if $conversationActive}
<span class="mic-icon conv">🎙️</span>
{:else if isRecording}
<span class="mic-icon recording"></span>
<div class="audio-level" style="height: {audioLevel}%"></div>
{:else}
<span class="mic-icon">🎤</span>
{/if}
</button>
<button
class="send-button"
onclick={sendMessage}
disabled={!$currentInput.trim() || isRecording}
>
{#if $isProcessing}
📬
{:else}
{/if}
</button>
</div>
</div>
<!-- ChatStatusBar entfernt (Phase 9): globale StatusBar in +layout uebernimmt -->
</div>
<!-- Quick-Actions Palette (Ctrl+K) -->
<QuickActions bind:visible={showQuickActions} onExecute={handleQuickAction} />
<!-- "Das merken" Dialog -->
{#if rememberDialogOpen}
<div class="modal-backdrop" onclick={closeRememberDialog}>
<div class="modal-dialog" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>💡 Das merken</h3>
<button class="close-btn" onclick={closeRememberDialog}>✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="rem-title">Titel *</label>
<input
type="text"
id="rem-title"
bind:value={rememberEntry.title}
placeholder="Kurze Beschreibung der Erkenntnis"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="rem-category">Kategorie</label>
<select id="rem-category" bind:value={rememberEntry.category}>
{#each knowledgeCategories as cat}
<option value={cat.id}>{cat.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="rem-priority">Priorität</label>
<select id="rem-priority" bind:value={rememberEntry.priority}>
<option value={1}>1 - Kritisch</option>
<option value={2}>2 - Wichtig</option>
<option value={3}>3 - Normal</option>
<option value={4}>4 - Niedrig</option>
</select>
</div>
</div>
<div class="form-group">
<label for="rem-tags">Tags (kommagetrennt)</label>
<input
type="text"
id="rem-tags"
bind:value={rememberEntry.tags}
placeholder="z.B. rust, tauri, async"
/>
</div>
<div class="form-group">
<label for="rem-content">Inhalt *</label>
<textarea
id="rem-content"
bind:value={rememberContent}
rows="8"
placeholder="Der zu merkende Inhalt..."
></textarea>
</div>
{#if rememberError}
<div class="form-error">{rememberError}</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={closeRememberDialog}>Abbrechen</button>
<button
class="btn-primary"
onclick={saveToKnowledge}
disabled={rememberSaving || !rememberEntry.title.trim()}
>
{rememberSaving ? 'Speichern...' : 'In Wissensbasis speichern'}
</button>
</div>
</div>
</div>
{/if}
<!-- Compacting-Warnung Dialog -->
{#if showCompactingDialog}
<div class="modal-backdrop" onclick={dismissCompactingDialog}>
<div class="modal-dialog compact-dialog" onclick={(e) => e.stopPropagation()}>
<div class="modal-header warning">
<h3>⚠️ Hohe Token-Anzahl</h3>
<button class="close-btn" onclick={dismissCompactingDialog}>✕</button>
</div>
<div class="modal-body">
<p class="warning-text">
Diese Konversation hat bereits <strong>~{(estimatedTokens / 1000).toFixed(0)}k Token</strong>.
</p>
<p>
Um Kosten zu sparen und das Context-Limit nicht zu überschreiten, kannst du ältere Nachrichten zusammenfassen lassen.
</p>
<div class="compact-info">
<div class="info-row">
<span>Aktuelle Nachrichten:</span>
<strong>{$messages.length}</strong>
</div>
<div class="info-row">
<span>Nach Compacting behalten:</span>
<strong>{KEEP_LAST_MESSAGES} neueste</strong>
</div>
<div class="info-row">
<span>Geschätzte Token danach:</span>
<strong>~{Math.ceil(estimatedTokens * (KEEP_LAST_MESSAGES / $messages.length) / 1000)}k</strong>
</div>
</div>
<p class="hint">
Die älteren Nachrichten werden zu einer Zusammenfassung komprimiert, bleiben aber in der Datenbank erhalten.
</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={dismissCompactingDialog}>Später</button>
<button class="btn-primary" onclick={performCompacting}>
📦 Jetzt kompaktieren
</button>
</div>
</div>
</div>
{/if}
<style>
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0; /* Flex-Kinder korrekt begrenzen (WebKitGTK) */
}
.chat-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
padding: var(--sp-1) var(--sp-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
min-height: 28px;
gap: var(--sp-1);
}
.tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--r-sm);
color: var(--text-secondary);
background: transparent;
font-size: 14px;
line-height: 1;
border: 0;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.tool-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.token-count {
font-size: 0.625rem;
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
color: var(--success);
font-weight: 500;
}
.token-count.warning {
background: rgba(234, 179, 8, 0.15);
color: var(--status-warning);
}
.token-count.danger {
background: rgba(239, 68, 68, 0.15);
color: var(--error);
}
.detach-btn {
padding: 0.2rem 0.45rem;
background: var(--bg-tertiary, #2a2a3e);
border: 1px solid var(--border, #333);
border-radius: var(--radius-sm, 4px);
color: var(--text-secondary, #aaa);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
transition: all 0.15s ease;
}
.detach-btn:hover {
background: var(--accent, #6c8aff);
color: white;
border-color: var(--accent, #6c8aff);
}
.chat-messages-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative; /* fuer Back-to-Bottom-Button absolute */
background: var(--vscode-editor-background);
}
.edit-overlay {
padding: 8px 12px;
background: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-input-border);
display: flex;
flex-direction: column;
gap: 6px;
}
.edit-textarea {
width: 100%;
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-focusBorder);
border-radius: 3px;
padding: 6px 8px;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.edit-btn {
font-size: 11.5px;
padding: 4px 10px;
border-radius: 2px;
}
.edit-btn.cancel {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-foreground);
}
.edit-btn.cancel:hover { background: var(--vscode-button-secondaryHoverBackground); }
.edit-btn.confirm {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.edit-btn.confirm:hover { background: var(--vscode-button-hoverBackground); }
.edit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
}
.empty-icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
opacity: 0.5;
}
.empty-state .hint {
font-size: 0.75rem;
margin-top: var(--spacing-sm);
color: var(--text-secondary);
opacity: 0.7;
}
.message {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
background: var(--bg-secondary);
max-width: 85%;
}
.message.user {
background: var(--bg-tertiary);
margin-left: auto;
}
.message.queued {
opacity: 0.6;
border-left: 3px solid var(--accent);
animation: pulse-queued 2s ease-in-out infinite;
}
@keyframes pulse-queued {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.8; }
}
.message.assistant {
margin-right: auto;
}
.message.system {
background: rgba(233, 69, 96, 0.1);
border-left: 3px solid var(--accent);
max-width: 100%;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.message-role {
font-size: 0.7rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.35rem;
}
.role-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.role-dot.user {
background: var(--accent, #6c8aff);
}
.role-dot.assistant {
background: var(--accent);
box-shadow: 0 0 6px rgba(167, 139, 250, 0.4);
}
.role-dot.system {
background: var(--text-secondary, #888);
}
.role-dot.queued {
background: var(--status-warning);
animation: pulse-dot 1.5s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.7); }
}
.role-tag {
font-size: 0.55rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
background: rgba(245, 158, 11, 0.15);
color: var(--status-warning);
font-weight: 500;
}
.message-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.action-btn {
padding: 0.15rem 0.3rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.message:hover .action-btn {
opacity: 0.6;
}
.action-btn:hover {
opacity: 1 !important;
background: var(--bg-tertiary);
}
.message-time {
font-size: 0.6rem;
color: var(--text-secondary);
margin-left: 0.5rem;
font-variant-numeric: tabular-nums;
}
/* Edit-Modus */
.message.editing {
border: 1px solid var(--accent);
}
.edit-textarea {
width: 100%;
min-height: 60px;
padding: var(--spacing-sm);
font-size: 0.85rem;
line-height: 1.5;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
resize: vertical;
font-family: inherit;
}
.edit-textarea:focus {
outline: none;
border-color: var(--accent);
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.edit-btn {
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.edit-btn.cancel {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.edit-btn.cancel:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.edit-btn.confirm {
background: var(--accent);
border: 1px solid var(--accent);
color: white;
}
.edit-btn.confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.edit-btn.confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.message-content {
font-size: 0.85rem;
line-height: 1.6;
}
.msg-collapsed {
overflow: hidden;
}
.expand-btn {
display: block;
width: 100%;
padding: 0.35rem;
margin-top: 0.3rem;
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
color: var(--accent, #6aa3ff);
font-size: 0.75rem;
cursor: pointer;
text-align: center;
transition: background 0.15s;
}
.expand-btn:hover {
background: var(--bg-hover, #333);
}
/* Thinking-Inline — kompakte, immer sichtbare Denkbloecke */
.message-content :global(.thinking-inline) {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
margin: 0.3rem 0;
background: rgba(99, 102, 241, 0.06);
border-left: 2px solid rgba(99, 102, 241, 0.3);
border-radius: 0 4px 4px 0;
font-size: 0.72rem;
line-height: 1.4;
color: var(--text-secondary, #9ca3af);
}
.message-content :global(.thinking-label) {
flex-shrink: 0;
font-size: 0.7rem;
}
.message-content :global(.thinking-text) {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
max-height: 200px;
overflow-y: auto;
}
.message-content :global(.thinking-text::-webkit-scrollbar) {
width: 4px;
}
.message-content :global(.thinking-text::-webkit-scrollbar-thumb) {
background: #374151;
border-radius: 2px;
}
/* Aktivitätsanzeige — kompakte Zeile mit Icon, Label und Dots */
.activity-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0;
margin: 0.2rem 0;
font-size: 0.78rem;
color: var(--text-secondary, #9ca3af);
animation: fadeInActivity 0.25s ease;
}
.activity-icon {
font-size: 0.85rem;
flex-shrink: 0;
}
.activity-label {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.activity-dots {
display: inline-flex;
gap: 3px;
margin-left: 2px;
flex-shrink: 0;
}
.activity-dot {
width: 4px;
height: 4px;
background: currentColor;
border-radius: 50%;
opacity: 0.5;
animation: dotPulse 1.2s infinite ease-in-out;
}
.activity-dot:nth-child(1) { animation-delay: 0s; }
.activity-dot:nth-child(2) { animation-delay: 0.2s; }
.activity-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotPulse {
0%, 60%, 100% { opacity: 0.3; transform: scale(1); }
30% { opacity: 1; transform: scale(1.3); }
}
@keyframes fadeInActivity {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
/* Markdown-Styles innerhalb von Nachrichten */
.message-content :global(p) {
margin: 0.3em 0;
}
.message-content :global(p:first-child) {
margin-top: 0;
}
.message-content :global(p:last-child) {
margin-bottom: 0;
}
.message-content :global(code) {
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.1em 0.35em;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
/* Code-Block mit Header und Copy-Button */
.message-content :global(.code-block-wrapper) {
margin: 0.5em 0;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
overflow: hidden;
}
.message-content :global(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.3rem 0.75rem;
background: rgba(0, 0, 0, 0.15);
font-size: 0.65rem;
gap: 0.5rem;
}
.message-content :global(.code-lang) {
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-content :global(.copy-btn) {
margin-left: auto;
padding: 0.2rem 0.5rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.message-content :global(.copy-btn:hover) {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.message-content :global(.copy-btn .copied) {
color: var(--success);
}
.message-content :global(.code-block-wrapper pre) {
margin: 0;
padding: var(--spacing-sm) var(--spacing-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.5;
}
.message-content :global(.code-block-wrapper pre code) {
padding: 0;
background: none;
}
/* Fallback für inline pre (ohne wrapper) */
.message-content :global(pre:not(.code-block-wrapper pre)) {
margin: 0.5em 0;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.75rem;
line-height: 1.4;
}
.message-content :global(pre:not(.code-block-wrapper pre) code) {
padding: 0;
background: none;
}
.message-content :global(ul), .message-content :global(ol) {
margin: 0.3em 0;
padding-left: 1.5em;
}
.message-content :global(li) {
margin: 0.15em 0;
}
.message-content :global(strong) {
color: var(--text-primary);
}
.message-content :global(a) {
color: var(--accent);
text-decoration: none;
}
.message-content :global(a:hover) {
text-decoration: underline;
}
.message-content :global(blockquote) {
border-left: 3px solid var(--bg-tertiary);
padding-left: var(--spacing-sm);
margin: 0.3em 0;
color: var(--text-secondary);
}
.message-content :global(h1), .message-content :global(h2), .message-content :global(h3) {
margin: 0.5em 0 0.2em;
font-size: 0.9rem;
}
.message-content :global(table) {
border-collapse: collapse;
margin: 0.3em 0;
font-size: 0.75rem;
width: 100%;
}
.message-content :global(th), .message-content :global(td) {
border: 1px solid var(--bg-tertiary);
padding: 0.3em 0.5em;
}
.message-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Alte Typing-Animation entfernt — ersetzt durch .activity-indicator */
/* Input-Bereich */
/* Pending Changes (Accept/Reject DiffView) */
.pending-changes {
border-top: 2px solid var(--accent);
background: var(--bg-secondary);
max-height: 300px;
overflow-y: auto;
flex-shrink: 0;
}
.pending-changes-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.7rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.btn-accept-all {
padding: 2px 8px;
font-size: 0.6rem;
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
border-radius: var(--radius-sm);
color: var(--success);
cursor: pointer;
font-weight: 600;
}
.btn-accept-all:hover {
background: var(--success);
color: white;
}
.chat-input {
display: flex;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--bg-tertiary);
position: relative;
flex-shrink: 0; /* Nicht schrumpfen — Eingabe bleibt immer sichtbar */
max-height: 160px; /* Puffer für mehrzeilige Eingabe */
}
.chat-input textarea {
flex: 1;
resize: none;
font-size: 0.85rem;
line-height: 1.4;
max-height: 120px; /* Max ~6 Zeilen, dann scrollen */
overflow-y: auto;
}
/* Phase 11: Toggle-Spalte links vom Textfeld (Approval-Modus). */
.input-prefix {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-self: flex-end;
}
.perm-toggle {
width: 36px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 1.15rem;
cursor: pointer;
transition: all 0.18s ease;
}
.perm-toggle:hover {
background: var(--bg-tertiary);
}
.perm-toggle.active {
background: rgba(0, 122, 204, 0.15);
border-color: var(--accent, #007acc);
box-shadow: 0 0 0 1px var(--accent, #007acc);
}
.perm-toggle.yolo {
background: rgba(244, 135, 113, 0.18);
border-color: var(--status-error, #f48771);
box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 12px rgba(244, 135, 113, 0.35);
animation: yolo-pulse 1.6s ease-in-out infinite;
}
@keyframes yolo-pulse {
0%, 100% { box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 12px rgba(244, 135, 113, 0.30); }
50% { box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 20px rgba(244, 135, 113, 0.55); }
}
.send-button {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: white;
font-size: 1.2rem;
font-weight: 600;
border-radius: var(--radius-md);
transition: all 0.2s ease;
align-self: flex-end;
}
.send-button:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.05);
}
.send-button:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: not-allowed;
transform: none;
}
/* Voice Interface */
.input-buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.mic-button {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.mic-button:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--accent);
}
.mic-button.recording {
background: rgba(239, 68, 68, 0.15);
border-color: var(--status-error);
animation: pulse-recording 1.5s ease-in-out infinite;
}
.mic-button.conversation {
background: rgba(124, 140, 255, 0.18);
border-color: var(--accent, #7c8cff);
animation: pulse-conversation 1.6s ease-in-out infinite;
}
@keyframes pulse-recording {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}
@keyframes pulse-conversation {
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 140, 255, 0.45); }
50% { box-shadow: 0 0 0 10px rgba(124, 140, 255, 0); }
}
.mic-icon {
z-index: 1;
}
.mic-icon.recording {
color: var(--status-error);
}
.audio-level {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(239, 68, 68, 0.4), rgba(239, 68, 68, 0.1));
transition: height 0.05s ease-out;
pointer-events: none;
}
.mic-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.live-transcript {
position: absolute;
top: -32px;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--status-error);
}
/* Modus-Anzeige im Eingabebereich */
.mode-indicator {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 4px var(--spacing-sm);
margin-bottom: var(--spacing-xs);
border-radius: var(--radius-sm);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
animation: mode-fade-in 0.3s ease;
}
@keyframes mode-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.mode-indicator.mode-handlanger {
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.mode-indicator.mode-experten {
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.3);
color: var(--accent);
}
.mode-indicator.mode-auto {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.mode-icon {
flex-shrink: 0;
font-size: 0.8rem;
}
.mode-label {
flex-shrink: 0;
}
.mode-phase {
opacity: 0.7;
font-weight: 400;
}
.queued-pill {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
background: rgba(96, 165, 250, 0.08);
border: 1px solid rgba(96, 165, 250, 0.25);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: #93c5fd;
}
.queued-icon {
flex-shrink: 0;
}
.queued-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queued-cancel {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.9rem;
padding: 0 4px;
opacity: 0.7;
}
.queued-cancel:hover {
opacity: 1;
}
.transcript-icon {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.transcript-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Modal Styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: var(--bg-primary);
border-radius: var(--radius-md);
width: 90%;
max-width: 500px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 0.9rem;
}
.close-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
font-size: 1rem;
}
.close-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-body {
padding: var(--spacing-md);
overflow-y: auto;
flex: 1;
}
.form-group {
margin-bottom: var(--spacing-sm);
}
.form-group label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.form-group textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
.form-error {
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: var(--radius-sm);
color: var(--error);
font-size: 0.75rem;
margin-top: var(--spacing-sm);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.btn-secondary {
padding: 0.5rem 1rem;
font-size: 0.8rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
}
.btn-secondary:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-primary {
padding: 0.5rem 1rem;
font-size: 0.8rem;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: white;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Compacting Dialog */
.compact-dialog {
max-width: 420px;
}
.modal-header.warning {
background: rgba(234, 179, 8, 0.15);
border-bottom-color: rgba(234, 179, 8, 0.3);
}
.modal-header.warning h3 {
color: var(--status-warning);
}
.warning-text {
font-size: 1rem;
margin-bottom: var(--spacing-sm);
}
.compact-info {
background: var(--bg-secondary);
border-radius: var(--radius-sm);
padding: var(--spacing-sm);
margin: var(--spacing-sm) 0;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8rem;
}
.info-row span {
color: var(--text-secondary);
}
.modal-body .hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--spacing-sm);
}
/* File-Drop Overlay */
.chat-panel.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
.drop-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(124, 58, 237, 0.12);
backdrop-filter: blur(4px);
border-radius: inherit;
pointer-events: none;
}
.drop-icon {
font-size: 3rem;
animation: dropBounce 0.5s ease;
}
@keyframes dropBounce {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.drop-overlay p {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--accent);
}
.drop-hint {
font-size: 0.75rem !important;
font-weight: 400 !important;
color: var(--text-secondary) !important;
}
</style>