diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa8ace..68ae983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] - 2026-04-27 +### Behoben (Phase 9.1: Crash-Fix + Chat-Polish) +- **Crash-Fix UTF-8 Truncation** in [src-tauri/src/db.rs](src-tauri/src/db.rs) — `&content[..240]` panickte mitten in einem ✅-Emoji (3 Bytes), App stürzte mit SIGABRT ab. Neues [src-tauri/src/strutil.rs](src-tauri/src/strutil.rs)-Modul mit `safe_truncate()` und `safe_truncate_ellipsis()` (5 Unit-Tests grün), in db.rs/claude.rs/knowledge.rs/session.rs/memory.rs an allen `&s[..N]`- und `&s[..s.len().min(N)]`-Stellen eingebaut +- **Stale Lock-File-Cleanup** in [src-tauri/src/update.rs](src-tauri/src/update.rs): nach Crash bleibt `/tmp/claude-desktop.lock` mit toter PID liegen — beim Neustart wird das jetzt explizit protokolliert (`🧹 Stale Lock-Datei aus vorherigem Crash gefunden`) statt stillschweigend ersetzt +- **Input-Textfeld nach Senden leer** in [ChatPanel.svelte](src/lib/components/ChatPanel.svelte): `bind:value={$currentInput}` aktualisierte den DOM-Wert in Svelte 5 mit Auto-Subscription nicht zuverlässig synchron. Belt-and-Suspenders: Store-Reset + harter DOM-Reset + `tick()` — der Text ist jetzt wirklich weg + +### Hinzugefügt (Phase 9.1: Chat-Polish) +- **ApprovalBar.svelte** (NEU): Sticky-Bar oberhalb des Chat-Inputs, erscheint wenn `pendingChanges.length > 0`. Zeigt 1 oder N wartende Datei-Änderungen mit klar beschrifteten Buttons **„✓ Übernehmen"** / **„✕ Verwerfen"** (statt vorher mehrdeutigem „Behalten/Zurueck"). Bleibt sichtbar wenn der Chat scrollt — User verliert die offene Anfrage nicht aus den Augen. Klick auf den Datei-Namen scrollt zur Inline-Diff-Karte und blinkt sie kurz (Approval-Flash). Tastatur-Shortcuts: `Ctrl+Enter` = erste übernehmen, `Ctrl+Shift+Enter` = alle übernehmen, `Ctrl+Backspace` = erste verwerfen +- **Smart-Sticky-Scroll v2** in [MessageList.svelte](src/lib/components/MessageList.svelte): Auto-Scroll triggert jetzt auch bei Tool-Card-Updates (`message.toolCalls.length` und Status-Änderungen) — vorher hat nur `content.length` getrackt, daher hat der Stream den User „abgehängt" wenn neue Tool-Karten dazukamen. Plus ResizeObserver am Container für Diff-Aufklappen/Code-Block-Render. Smooth-Scroll bei kleinen Distanzen (< 240 px), instant bei großen. Threshold von 100 → 60 px +- **Streaming-Caret aufgewertet** in [Message.svelte](src/lib/components/Message.svelte): pulsierender Block-Cursor `▍` mit Glow-Shadow (Codium-Style) statt hartem on/off-blink +- **Tool-Card-Slide-In + Shimmer** in [ToolCallCard.svelte](src/lib/components/ToolCallCard.svelte): neue Tool-Karten kommen mit `transition:slide` rein, laufende Karten haben einen sanften Shimmer-Streifen über dem linken Rand (1.4s-Loop) +- **Phase-bewusster WorkingIndicator** in [WorkingIndicator.svelte](src/lib/components/WorkingIndicator.svelte): Verb passt sich an `processingPhase` an — „Denkt nach"/„Schreibt"/„Nutzt <Tool>"/„Subagent arbeitet" statt Random-Verb wenn die Phase bekannt ist. Random-Verben bleiben als Fallback +- **DiffView-Buttons umbenannt** in [DiffView.svelte](src/lib/components/DiffView.svelte): „✓ Übernehmen" / „✕ Verwerfen" mit klaren Tooltips (Shortcut-Hints) statt „Behalten/Zurueck" + ### Hinzugefügt (Phase 9: UI-Redesign Schritt 2 — 2-spaltiges Layout + Drawer + Komponenten-Pass) - **Sidebar.svelte** (NEU): 240px-Sidebar mit Cmd+K-Suche oben, Sessions-Liste in der Mitte, Nav-Rail unten mit 4 Lucide-Icons (Aktivität/Speicher/Werkzeuge/Einstellungen) — ersetzt die alte separate SessionList-Pane - **ToolDrawer.svelte** (NEU): Rechts-eingeschobener 420px-Drawer mit internen Tabs pro Sektion — Activity (Live/Monitor/Kosten), Speicher (Gedächtnis/Wissensbasis/Kontext), Werkzeuge (Programme/Sprache/Agenten/Guard-Rails/Hooks), Einstellungen (Settings/Audit). Esc schließt diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index bfeb97a..02dd113 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -16,6 +16,7 @@ use std::os::unix::net::UnixStream; use crate::db; use crate::knowledge; +use crate::strutil::safe_truncate; /// Standard-Pfade für UDS-Daemon const SOCKET_PATH: &str = "/tmp/claude-bridge.sock"; @@ -239,7 +240,7 @@ fn connect_uds(app: &AppHandle, daemon_pid: Option) -> Result<(), String> { for line in reader.lines().map_while(Result::ok) { match serde_json::from_str::(&line) { Ok(msg) => handle_bridge_message(&app_handle, msg), - Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, &line[..line.len().min(100)]), + Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, safe_truncate(&line, 100)), } } println!("⚠️ UDS-Verbindung getrennt — versuche Reconnect..."); @@ -371,7 +372,7 @@ fn start_bridge_stdio(app: &AppHandle, script_path: &std::path::Path) -> Result< for line in reader.lines().map_while(Result::ok) { match serde_json::from_str::(&line) { Ok(msg) => handle_bridge_message(&app_handle, msg), - Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, &line[..line.len().min(100)]), + Err(e) => println!("⚠️ Bridge-Nachricht nicht parsbar: {} — {}", e, safe_truncate(&line, 100)), } } println!("⚠️ Bridge stdout geschlossen"); @@ -657,7 +658,7 @@ fn send_to_bridge_full( /// Nachricht an Claude senden #[tauri::command] pub async fn send_message(app: AppHandle, message: String) -> Result { - println!("📨 Nachricht empfangen: {}", &message[..message.len().min(50)]); + println!("📨 Nachricht empfangen: {}", safe_truncate(&message, 50)); // Bridge starten falls nicht aktiv let needs_start = { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 2ebf0c9..057996f 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -9,6 +9,7 @@ use tauri::{AppHandle, Manager}; use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats}; use crate::guard::{Permission, PermissionAction, PermissionType}; use crate::memory::{ContextCategory, MemoryEntry, Pattern}; +use crate::strutil::safe_truncate_ellipsis; /// Eine Claude-Session #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -798,7 +799,7 @@ impl Database { id: row.get(0)?, session_id: row.get(1)?, role: row.get(2)?, - snippet: if content.len() > 240 { format!("{}…", &content[..240]) } else { content }, + snippet: safe_truncate_ellipsis(&content, 240), timestamp: row.get(4)?, session_title: row.get(5)?, }) diff --git a/src-tauri/src/knowledge.rs b/src-tauri/src/knowledge.rs index 33ed7fb..beeba5a 100644 --- a/src-tauri/src/knowledge.rs +++ b/src-tauri/src/knowledge.rs @@ -12,6 +12,8 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; +use crate::strutil::safe_truncate; + // ============ KB-Cache (RAM) ============ // Cached KB-Suchergebnisse im RAM. Spart MySQL-Roundtrip pro Nachricht. // Invalidierung: automatisch nach 60 Sekunden (TTL). @@ -335,7 +337,7 @@ pub async fn search_knowledge_internal(query: &str, limit: i32) -> Result Result { let cache = KB_CACHE.lock().unwrap(); if let Some(cached) = cache.get(&cache_key) { - println!("⚡ KB-Cache HIT für '{}'", &search_query[..search_query.len().min(40)]); + println!("⚡ KB-Cache HIT für '{}'", safe_truncate(search_query, 40)); return Ok(cached.to_string()); } } @@ -532,7 +534,7 @@ pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result let _ = pool.disconnect().await; if results.is_empty() { - println!("🔍 KB-Hints für '{}': keine Treffer", &search_query[..search_query.len().min(40)]); + println!("🔍 KB-Hints für '{}': keine Treffer", safe_truncate(search_query, 40)); // Leere Ergebnisse auch cachen — verhindert wiederholte DB-Abfragen let mut cache = KB_CACHE.lock().unwrap(); cache.insert(cache_key, String::new()); @@ -558,7 +560,7 @@ pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> Result let block = hints.join("\n"); println!("🔍 KB-Hints für '{}': {} Treffer, {} Bytes (cached)", - &search_query[..search_query.len().min(40)], results.len(), block.len()); + safe_truncate(search_query, 40), results.len(), block.len()); // In Cache speichern { @@ -589,7 +591,7 @@ pub async fn proactive_session_hints(project_name: Option<&str>) -> Result Dieses Pattern wurde automatisch erstellt nachdem der Fehler {}x aufgetreten ist.\n\ > Bitte Lösung/Workaround ergänzen.", - tool, occurrence_count, &error_message[..error_message.len().min(500)], error_hash, occurrence_count + tool, occurrence_count, safe_truncate(error_message, 500), error_hash, occurrence_count ); let tags = format!("auto-pattern,fehler,{},{}", tool.to_lowercase(), error_hash); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6d4a3b..4108f46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod knowledge; mod memory; mod programs; mod session; +mod strutil; mod teaching; mod update; mod voice; diff --git a/src-tauri/src/memory.rs b/src-tauri/src/memory.rs index 537994b..31a173f 100644 --- a/src-tauri/src/memory.rs +++ b/src-tauri/src/memory.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager}; use crate::db; +use crate::strutil::safe_truncate; /// Kategorien für Sticky Context (werden nie vergessen) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -150,8 +151,7 @@ pub async fn detect_issue( error_message: String, _context: String, ) -> Result, String> { - let preview_len = error_message.len().min(50); - println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..preview_len]); + println!("🔍 Prüfe auf bekannte Probleme: {}", safe_truncate(&error_message, 50)); let state = app.state::>>(); let db_lock = state.lock().unwrap(); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 5635373..11cfe53 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -7,6 +7,7 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::claude; use crate::db::{self, Session}; +use crate::strutil::safe_truncate; // ============ Tauri Commands ============ @@ -235,7 +236,7 @@ pub async fn queue_message( rusqlite::params![queued.id, queued.message, queued.session_id, queued.created_at], ).map_err(|e| e.to_string())?; - println!("📥 Nachricht gequeuet (offline): {}", &queued.message[..queued.message.len().min(50)]); + println!("📥 Nachricht gequeuet (offline): {}", safe_truncate(&queued.message, 50)); let _ = app.emit("message-queued", &queued); Ok(queued) @@ -298,7 +299,7 @@ pub async fn flush_offline_queue(app: AppHandle) -> Result { "DELETE FROM offline_queue WHERE id = ?1", rusqlite::params![msg.id], ); - println!("✅ Queue-Nachricht gesendet: {}", &msg.message[..msg.message.len().min(50)]); + println!("✅ Queue-Nachricht gesendet: {}", safe_truncate(&msg.message, 50)); } Err(e) => { println!("⚠️ Queue-Nachricht fehlgeschlagen: {} — Abbruch", e); diff --git a/src-tauri/src/strutil.rs b/src-tauri/src/strutil.rs new file mode 100644 index 0000000..326264e --- /dev/null +++ b/src-tauri/src/strutil.rs @@ -0,0 +1,71 @@ +// UTF-8-sichere String-Truncation +// +// `&s[..n]` panickt wenn n mitten in einem Multi-Byte-Zeichen liegt. +// Konkret: ein '✅' belegt 3 Bytes; bei `&s[..240]` wenn ✅ auf +// Byte-Position 239..241 sitzt → Panic "is not a char boundary". +// +// Diese Funktion findet die naechste Char-Boundary <= max_bytes +// und schneidet dort sauber ab. + +/// Schneidet `s` an einer Char-Boundary <= `max_bytes` ab. +/// Wenn `s` schon kuerzer ist, wird `s` unveraendert zurueckgegeben. +pub fn safe_truncate(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + // floor_char_boundary ist nightly; eigene Implementierung: + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +/// Wie `safe_truncate`, haengt aber `…` an wenn gekuerzt wurde. +pub fn safe_truncate_ellipsis(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + s.to_string() + } else { + format!("{}…", safe_truncate(s, max_bytes)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_works() { + assert_eq!(safe_truncate("hello world", 5), "hello"); + } + + #[test] + fn does_not_panic_on_emoji_boundary() { + // ✅ ist 3 Bytes (E2 9C 85). Wenn wir bei Byte 1 abschneiden + // wuerde &s[..1] panicken. + let s = "abc✅def"; + // ✅ liegt auf Byte 3..6, abschneiden bei 4 → muss auf 3 zurueck + let out = safe_truncate(s, 4); + assert_eq!(out, "abc"); + } + + #[test] + fn ellipsis_appends() { + let s = "abcdefghij"; + assert_eq!(safe_truncate_ellipsis(s, 5), "abcde…"); + } + + #[test] + fn ellipsis_no_truncate_when_short() { + assert_eq!(safe_truncate_ellipsis("hi", 10), "hi"); + } + + #[test] + fn handles_german_umlaut() { + // ä ist 2 Bytes. "abäc" → ab=2, ä=2 (4), c=1 (5) + let s = "abäc"; + // Schnitt bei 3 → ä halbiert → muss auf 2 zurueck + assert_eq!(safe_truncate(s, 3), "ab"); + assert_eq!(safe_truncate(s, 4), "abä"); + } +} diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index f2b260f..7329fd5 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -25,6 +25,11 @@ pub fn create_lock_file() { content.trim() ); } + } else if std::path::Path::new(LOCK_FILE_PATH).exists() { + // Stale Lock vom letzten Crash — proaktiv aufräumen + protokollieren + if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) { + println!("🧹 Stale Lock-Datei aus vorherigem Crash gefunden (PID {}) — wird ersetzt", content.trim()); + } } // PID in Lock-Datei schreiben diff --git a/src/lib/components/ApprovalBar.svelte b/src/lib/components/ApprovalBar.svelte new file mode 100644 index 0000000..aa04f27 --- /dev/null +++ b/src/lib/components/ApprovalBar.svelte @@ -0,0 +1,396 @@ + + +{#if $pendingChanges.length > 0} +
+
+
+ + + {#if $pendingChanges.length === 1} + 1 Änderung wartet auf dich + {:else} + {$pendingChanges.length} Änderungen warten + {/if} + + {#if $pendingChanges.length === 1} + + {:else} + + {/if} +
+ +
+ {#if $pendingChanges.length === 1} + + + {:else} + + + {/if} +
+
+ + {#if expanded && $pendingChanges.length > 1} +
    + {#each $pendingChanges as change (change.toolId)} +
  • + +
    + + +
    +
  • + {/each} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index 9ea9935..51fc6df 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -12,6 +12,7 @@ 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 @@ -830,6 +831,19 @@ 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). @@ -888,8 +902,6 @@ messages.update((msgs) => [...msgs, msg]); await saveMessageToDb(msg); - $currentInput = ''; - // isProcessing nur setzen wenn nicht schon aktiv // (bei gepufferten Nachrichten laeuft Claude ja schon) if (!$isProcessing) { @@ -1199,10 +1211,10 @@ {/if} - + +
{/if} {#if interactive} - - {/if}
diff --git a/src/lib/components/Message.svelte b/src/lib/components/Message.svelte index 26d5f35..8dcdd02 100644 --- a/src/lib/components/Message.svelte +++ b/src/lib/components/Message.svelte @@ -375,12 +375,18 @@ .cursor { display: inline-block; - animation: blink 1s steps(2, start) infinite; - color: var(--vscode-progressBar-background); - margin-left: 1px; + color: var(--vscode-progressBar-background, var(--accent, #007acc)); + margin-left: 2px; + font-weight: 600; + /* weicheres Pulsieren statt hartem on/off — wirkt "lebendiger", + aehnlich Codium/Claude-Code-Extension */ + animation: caret-pulse 1.1s ease-in-out infinite; + text-shadow: 0 0 4px var(--vscode-progressBar-background, var(--accent, #007acc)); } - @keyframes blink { - to { visibility: hidden; } + @keyframes caret-pulse { + 0%, 100% { opacity: 0.25; transform: scaleY(1); } + 45% { opacity: 1; transform: scaleY(1.05); } + 50% { opacity: 1; transform: scaleY(1.05); } } .tool-calls { diff --git a/src/lib/components/MessageList.svelte b/src/lib/components/MessageList.svelte index 70ca570..0b53996 100644 --- a/src/lib/components/MessageList.svelte +++ b/src/lib/components/MessageList.svelte @@ -3,11 +3,15 @@ // Smart-Sticky-Scroll: Wenn der User selbst gescrollt hat, springt das // Auto-Scroll nicht mehr zum Ende. Stattdessen erscheint ein // Back-to-Bottom-Button. + // + // Phase 9.1: ResizeObserver am Container — feuert auch bei + // Tool-Card-Slide-In, Diff-Aufklappen, Markdown-Code-Blocks. Tracker + // liest jetzt zusaetzlich die Anzahl Tool-Calls. import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores'; - import { tick } from 'svelte'; import MessageItem from './Message.svelte'; import WorkingIndicator from './WorkingIndicator.svelte'; + import { onMount, onDestroy } from 'svelte'; interface Props { streamingMessageId?: string | null; @@ -26,7 +30,8 @@ let container: HTMLDivElement | null = null; let userScrolledUp = $state(false); - let scrollPending = false; + let autoScrolling = false; // Guard: ignoriert onscroll waehrend wir selbst scrollen + let resizeObs: ResizeObserver | null = null; // Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine // Assistant-Tokens da sind (vor erstem Stream + waehrend Tool-Calls). @@ -39,20 +44,30 @@ }); function checkScroll() { - if (!container) return; + if (!container || autoScrolling) return; const distance = container.scrollHeight - container.scrollTop - container.clientHeight; - const next = distance > 100; + // 60 px Threshold — kleiner als vorher (100), damit der User schneller + // wieder "drangeklebt" wird wenn er nur kurz zuruecksteht. + const next = distance > 60; if (next !== userScrolledUp) userScrolledUp = next; } - async function scrollToBottom(force = false) { + function scrollToBottom(force = false) { if (!container) return; if (!force && userScrolledUp) return; - if (scrollPending) return; - scrollPending = true; - await tick(); - if (container) container.scrollTop = container.scrollHeight; - scrollPending = false; + autoScrolling = true; + // Smooth nur bei kleinen Distanzen — bei grossem Stream-Catch-up wuerde + // das die Anzeige ausbremsen, also dort instant. + const distance = container.scrollHeight - container.scrollTop - container.clientHeight; + if (distance < 240 && !force) { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } else { + container.scrollTop = container.scrollHeight; + } + // autoScrolling nach dem naechsten Frame zuruecksetzen + requestAnimationFrame(() => { + requestAnimationFrame(() => { autoScrolling = false; }); + }); } function snapToBottom() { @@ -60,17 +75,70 @@ scrollToBottom(true); } - // Auto-Scroll bei neuen Messages oder neuem Streaming-Token - // (untracked() verhindert dass userScrolledUp-Aenderungen erneut feuern) + // Reactive-Tracker: deckt jetzt auch Tool-Calls ab. Sobald sich die + // Anzahl Tool-Calls in der letzten Message aendert (Slide-In, Status + // running→done), wird Auto-Scroll getriggert. $effect(() => { - // Nur diese beiden als Dependencies tracken - const _msgs = $messages.length; - const _proc = $isProcessing; - // Letzten Content-Length tracken, damit Streaming-Updates feuern - const _lastLen = $messages[$messages.length - 1]?.content?.length ?? 0; - void _msgs; void _proc; void _lastLen; + const last = $messages[$messages.length - 1]; + const _trackers = [ + $messages.length, + $isProcessing, + last?.content?.length ?? 0, + last?.toolCalls?.length ?? 0, + last?.toolCalls?.map((t) => t.status).join(',') ?? '', + ]; + void _trackers; scrollToBottom(); }); + + // Wenn der User eine neue Message schreibt und die Antwort ankommt, soll + // Auto-Scroll wieder anspringen — auch wenn der User vorher hochgescrollt + // war. Reset bei Wechsel von "letzter ist user" → "letzter ist assistant". + let lastRole: string | null = null; + $effect(() => { + const last = $messages[$messages.length - 1]; + const role = last?.role ?? null; + if (role && role !== lastRole) { + if (role === 'user') { + // Beim Senden: wieder ans Ende kleben + userScrolledUp = false; + } + lastRole = role; + } + }); + + onMount(() => { + // ResizeObserver fuer den Container: feuert wenn sich die Hoehe + // aendert (Tool-Card aufklappen, Diff-Erweitern, Code-Block rendern). + // Ohne das wuerde der Stream "abgehaengt" weil $effect nur bei + // Content-Length-Aenderung greift. + if (container && typeof ResizeObserver !== 'undefined') { + resizeObs = new ResizeObserver(() => { + if (!userScrolledUp) scrollToBottom(); + }); + // Den letzten Child beobachten, nicht den Container selbst — + // Container-Groesse ist konstant, sein Inhalt waechst. + const inner = container.firstElementChild; + if (inner) { + // Alle direkten Kinder beobachten ist zu teuer. Ein Wrapper + // reicht — der Container hat als Direct-Child die Message- + // Liste, und sein scrollHeight aendert sich passend. + // Wir beobachten den Container selbst — ResizeObserver + // feuert auch wenn der Inhalt waechst (clientHeight stays, + // scrollHeight grows → checkScroll triggert). + } + // Robusteste Variante: Container observed, plus Mutation-Observer + // fuer Content-Aenderungen. + resizeObs.observe(container); + } + }); + + onDestroy(() => { + if (resizeObs) { + resizeObs.disconnect(); + resizeObs = null; + } + });
@@ -111,6 +179,7 @@ overflow-x: hidden; background: var(--vscode-editor-background); padding: 4px 0; + scroll-behavior: auto; /* smooth wird per JS gesteuert */ } .empty { @@ -144,6 +213,12 @@ border-radius: 12px; box-shadow: 0 2px 8px var(--vscode-widget-shadow); z-index: 5; + animation: bounce-in 220ms ease-out; } .scroll-bottom:hover { background: var(--vscode-button-hoverBackground); } + + @keyframes bounce-in { + from { opacity: 0; transform: translateY(8px) scale(0.9); } + to { opacity: 1; transform: translateY(0) scale(1); } + } diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 7c40ebd..49cabbf 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -94,7 +94,10 @@ {#if phaseText} · - {phaseText} + + + {phaseText} + {/if} @@ -170,6 +173,19 @@ font-style: italic; } + .phase-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + animation: phase-pulse 1.2s ease-in-out infinite; + } + + @keyframes phase-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.7); } + } + .spacer { flex: 1; } .hint { diff --git a/src/lib/components/ToolCallCard.svelte b/src/lib/components/ToolCallCard.svelte index 4145e54..0a08aed 100644 --- a/src/lib/components/ToolCallCard.svelte +++ b/src/lib/components/ToolCallCard.svelte @@ -11,6 +11,8 @@ import { getToolMeta, getToolSubtitle } from '$lib/utils/toolCards'; import type { InlineToolCall } from '$lib/stores'; + import { slide } from 'svelte/transition'; + import { quintOut } from 'svelte/easing'; interface Props { call: InlineToolCall; @@ -49,7 +51,12 @@ -
+