fix: UTF-8-Crash + Input-Reset + ApprovalBar + Scroll/Streaming-Polish [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m20s

Crash-Fix:
- src/db.rs:801 panickte mit "byte index 240 is not a char boundary"
  mitten in einem -Emoji → SIGABRT. Neues strutil-Modul mit
  safe_truncate()/safe_truncate_ellipsis() (5 Tests grün), an allen
  &s[..N]-Stellen in db/claude/knowledge/session/memory.rs eingebaut.
- update.rs: Stale Lock-Files vom letzten Crash werden jetzt
  protokolliert ("🧹 Stale Lock-Datei aus vorherigem Crash gefunden").

Chat-Polish:
- Input-Textfeld wird nach Senden zuverlässig geleert (Store-Reset +
  DOM-Reset + tick — Svelte 5 bind:value mit Auto-Subscription
  aktualisiert sonst nicht synchron).
- ApprovalBar.svelte (NEU): Sticky-Bar überm Input mit klar
  beschrifteten Buttons "Übernehmen"/"Verwerfen" statt mehrdeutigem
  "Behalten/Zurueck". Bleibt sichtbar wenn der Chat scrollt. Klick
  auf Datei-Name scrollt zur Inline-Karte und blinkt sie. Shortcuts
  Ctrl+Enter/Ctrl+Backspace.
- MessageList: Auto-Scroll trackt jetzt auch toolCalls.length und
  Status-Änderungen, plus ResizeObserver am Container. Smooth bei
  kleinen Distanzen, instant bei großen.
- Streaming-Caret: pulsierender Block-Cursor mit Glow-Shadow.
- Tool-Cards: Slide-In-Transition + Shimmer-Animation auf running.
- WorkingIndicator: Verb passt sich an processingPhase an.
This commit is contained in:
Eddy 2026-04-27 20:55:08 +02:00
parent 75a93987fe
commit 79f4f9fb21
17 changed files with 705 additions and 53 deletions

View file

@ -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 &lt;Tool&gt;"/„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

View file

@ -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<u32>) -> Result<(), String> {
for line in reader.lines().map_while(Result::ok) {
match serde_json::from_str::<BridgeMessage>(&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::<BridgeMessage>(&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<String, String> {
println!("📨 Nachricht empfangen: {}", &message[..message.len().min(50)]);
println!("📨 Nachricht empfangen: {}", safe_truncate(&message, 50));
// Bridge starten falls nicht aktiv
let needs_start = {

View file

@ -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)?,
})

View file

@ -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<String
let start = topic.keywords.len().saturating_sub(6);
topic.keywords[start..].join(" ")
} else if new_keywords.is_empty() {
query[..query.len().min(100)].to_string()
safe_truncate(query, 100).to_string()
} else {
new_keywords.join(" ")
};
@ -472,7 +474,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
let block = hints.join("\n");
println!("🔍 Smart Hints v2 für '{}': {} Treffer, {} Bytes",
&search_query[..search_query.len().min(40)], filtered.len(), block.len());
safe_truncate(search_query, 40), filtered.len(), block.len());
Ok(block)
}
@ -488,7 +490,7 @@ pub async fn search_knowledge_by_query(search_query: &str, limit: i32) -> 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<Strin
search_terms.push("fehler workaround aktiv".to_string());
let query = search_terms.join(" ");
println!("📋 Proaktive KB-Abfrage: '{}'", &query[..query.len().min(60)]);
println!("📋 Proaktive KB-Abfrage: '{}'", safe_truncate(&query, 60));
search_knowledge_by_query(&query, 5).await
}
@ -632,7 +634,7 @@ pub async fn save_error_pattern_to_kb(
}
// Neuen KB-Eintrag erstellen
let title = format!("Auto-Pattern: {} Fehler in {}", &error_message[..error_message.len().min(60)], tool);
let title = format!("Auto-Pattern: {} Fehler in {}", safe_truncate(error_message, 60), tool);
let content = format!(
"## Automatisch erkanntes Fehler-Pattern\n\n\
**Tool:** {}\n\
@ -641,7 +643,7 @@ pub async fn save_error_pattern_to_kb(
**Hash:** `{}`\n\n\
> 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);

View file

@ -25,6 +25,7 @@ mod knowledge;
mod memory;
mod programs;
mod session;
mod strutil;
mod teaching;
mod update;
mod voice;

View file

@ -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<Option<Pattern>, 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::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();

View file

@ -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<u32, String> {
"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);

71
src-tauri/src/strutil.rs Normal file
View file

@ -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ä");
}
}

View file

@ -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

View file

@ -0,0 +1,396 @@
<script lang="ts">
// Sticky-Bar oberhalb des Chat-Inputs, wenn pendingChanges vorhanden sind.
// Bleibt sichtbar egal wie weit der Chat scrollt — User verliert die
// offene Anfrage nicht aus den Augen. Klick auf den Datei-Namen scrollt
// zur Inline-DiffView in der Tool-Karte und highlightet sie kurz.
import { invoke } from '@tauri-apps/api/core';
import { pendingChanges, addMessage, type FileChange } from '$lib/stores/app';
import { slide, fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let busy = $state(false);
let expanded = $state(false);
function shortenPath(path: string): string {
const parts = path.split('/');
if (parts.length > 3) return `…/${parts.slice(-2).join('/')}`;
return path;
}
function toolIcon(tool: string): string {
if (tool === 'Edit' || tool === 'MultiEdit') return '✏️';
if (tool === 'Write') return '📝';
if (tool === 'NotebookEdit') return '📓';
return '📄';
}
async function accept(toolId: string) {
if (busy) return;
busy = true;
try {
await invoke('accept_change', { toolId });
pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId));
} catch (err) {
addMessage('system', `Fehler beim Übernehmen: ${err}`);
} finally {
busy = false;
}
}
async function reject(toolId: string) {
if (busy) return;
busy = true;
try {
const result = await invoke<string>('reject_change', { toolId });
pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId));
addMessage('system', `↩️ ${result}`);
} catch (err) {
addMessage('system', `Fehler beim Verwerfen: ${err}`);
} finally {
busy = false;
}
}
async function acceptAll() {
const list = [...$pendingChanges];
for (const c of list) {
await accept(c.toolId);
}
}
async function rejectAll() {
const list = [...$pendingChanges];
for (const c of list) {
await reject(c.toolId);
}
}
// Klick auf Eintrag → zur Inline-Card scrollen
function focusChange(change: FileChange) {
const sel = `[data-tool-id="${change.toolId}"]`;
const el = document.querySelector<HTMLElement>(sel);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('approval-flash');
setTimeout(() => el.classList.remove('approval-flash'), 1200);
}
}
// Tastatur-Shortcuts global registrieren
$effect(() => {
const handler = (e: KeyboardEvent) => {
if ($pendingChanges.length === 0) return;
// Im Textarea/Input keine Aktionen — sonst kann man nicht tippen
const target = e.target as HTMLElement;
if (target?.tagName === 'TEXTAREA' || target?.tagName === 'INPUT') return;
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) acceptAll();
else accept($pendingChanges[0].toolId);
} else if (e.ctrlKey && e.key === 'Backspace') {
e.preventDefault();
if (e.shiftKey) rejectAll();
else reject($pendingChanges[0].toolId);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
</script>
{#if $pendingChanges.length > 0}
<div
class="approval-bar"
role="region"
aria-label="Wartende Datei-Änderungen"
transition:fly={{ y: 12, duration: 200, easing: quintOut }}
>
<div class="bar-main">
<div class="bar-info">
<span class="pulse-dot" aria-hidden="true"></span>
<span class="count-label">
{#if $pendingChanges.length === 1}
<strong>1 Änderung</strong> wartet auf dich
{:else}
<strong>{$pendingChanges.length} Änderungen</strong> warten
{/if}
</span>
{#if $pendingChanges.length === 1}
<button
class="file-link"
type="button"
onclick={() => focusChange($pendingChanges[0])}
title="Zur Diff-Vorschau scrollen"
>
{toolIcon($pendingChanges[0].tool)} {shortenPath($pendingChanges[0].filePath)}
</button>
{:else}
<button
class="expand-btn"
type="button"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
title="Liste ein-/ausblenden"
>
{expanded ? '▴' : '▾'} Liste
</button>
{/if}
</div>
<div class="bar-actions">
{#if $pendingChanges.length === 1}
<button
class="btn btn-reject"
type="button"
onclick={() => reject($pendingChanges[0].toolId)}
disabled={busy}
title="Änderung verwerfen — Datei bleibt unverändert (Ctrl+Backspace)"
>
✕ Verwerfen
</button>
<button
class="btn btn-accept"
type="button"
onclick={() => accept($pendingChanges[0].toolId)}
disabled={busy}
title="Änderung auf die Datei anwenden (Ctrl+Enter)"
>
✓ Übernehmen
</button>
{:else}
<button
class="btn btn-reject"
type="button"
onclick={rejectAll}
disabled={busy}
title="Alle Änderungen verwerfen (Ctrl+Shift+Backspace)"
>
✕ Alle verwerfen
</button>
<button
class="btn btn-accept"
type="button"
onclick={acceptAll}
disabled={busy}
title="Alle Änderungen übernehmen (Ctrl+Shift+Enter)"
>
✓ Alle übernehmen
</button>
{/if}
</div>
</div>
{#if expanded && $pendingChanges.length > 1}
<ul class="change-list" transition:slide={{ duration: 160 }}>
{#each $pendingChanges as change (change.toolId)}
<li>
<button
class="file-link list-item"
type="button"
onclick={() => focusChange(change)}
>
<span class="icon">{toolIcon(change.tool)}</span>
<span class="name">{shortenPath(change.filePath)}</span>
</button>
<div class="row-actions">
<button
class="btn btn-mini btn-reject"
type="button"
onclick={() => reject(change.toolId)}
disabled={busy}
title="Verwerfen"
>
</button>
<button
class="btn btn-mini btn-accept"
type="button"
onclick={() => accept(change.toolId)}
disabled={busy}
title="Übernehmen"
>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<style>
.approval-bar {
flex-shrink: 0;
background: var(--bg-secondary, #252526);
border-top: 2px solid var(--accent, #007acc);
border-bottom: 1px solid var(--border, #3c3c3c);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.18);
font-size: 12px;
}
.bar-main {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
gap: 12px;
}
.bar-info {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent, #007acc);
flex-shrink: 0;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); box-shadow: 0 0 6px var(--accent, #007acc); }
}
.count-label {
color: var(--text-secondary, #cccccc);
flex-shrink: 0;
}
.count-label strong {
color: var(--text-primary, #ffffff);
font-weight: 600;
}
.file-link {
background: var(--bg-tertiary, #2d2d30);
border: 1px solid var(--border, #3c3c3c);
color: var(--text-primary, #ddd);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-family: var(--font-mono, monospace);
font-size: 11px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background 0.15s;
}
.file-link:hover {
background: var(--bg-hover, #3c3c3c);
border-color: var(--accent, #007acc);
}
.expand-btn {
background: transparent;
border: 1px solid var(--border, #3c3c3c);
color: var(--text-secondary, #ccc);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.expand-btn:hover {
background: var(--bg-hover, #3c3c3c);
}
.bar-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.btn {
padding: 4px 12px;
border-radius: 3px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-accept {
background: var(--accent, #007acc);
border-color: var(--accent, #007acc);
color: white;
}
.btn-accept:hover:not(:disabled) {
background: #1184d8;
border-color: #1184d8;
}
.btn-reject {
background: transparent;
border-color: var(--border, #3c3c3c);
color: var(--text-secondary, #ccc);
}
.btn-reject:hover:not(:disabled) {
background: rgba(244, 135, 113, 0.15);
border-color: var(--status-error, #f48771);
color: var(--status-error, #f48771);
}
.change-list {
list-style: none;
margin: 0;
padding: 0 12px 8px 12px;
max-height: 220px;
overflow-y: auto;
}
.change-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
gap: 8px;
border-radius: 3px;
}
.change-list li:hover {
background: var(--bg-tertiary, #2d2d30);
}
.list-item {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
text-align: left;
}
.list-item .icon { flex-shrink: 0; }
.list-item .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.row-actions { display: flex; gap: 4px; flex-shrink: 0; }
.btn-mini {
padding: 2px 8px;
font-size: 11px;
min-width: 28px;
}
/* Highlight-Animation auf der Inline-Card beim Anklicken — wird global
erkannt wenn .approval-flash auf einer ToolCard gesetzt ist */
:global(.approval-flash) {
animation: approval-flash 1.2s ease-out;
}
@keyframes approval-flash {
0%, 100% { box-shadow: none; }
50% { box-shadow: 0 0 0 3px var(--accent, #007acc); }
}
</style>

View file

@ -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 @@
</div>
{/if}
<!-- Pending-Changes-Block entfernt (Phase 8): DiffView wird jetzt inline
in ToolCardEdit innerhalb der Assistant-Message gerendert. Backend-Logik
(acceptChange/rejectChange) bleibt in dieser Datei und wird ueber
den pendingChanges-Store von ToolCardEdit aufgerufen. -->
<!-- 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

View file

@ -186,11 +186,11 @@
</button>
{/if}
{#if interactive}
<button class="btn-accept" onclick={handleAccept} title="Aenderung behalten">
✅ Behalten
<button class="btn-accept" onclick={handleAccept} title="Diese Aenderung auf die Datei anwenden (Ctrl+Enter)">
✓ Übernehmen
</button>
<button class="btn-reject" onclick={handleReject} title="Aenderung rueckgaengig machen">
↩️ Zurueck
<button class="btn-reject" onclick={handleReject} title="Aenderung verwerfen, Datei bleibt unveraendert (Ctrl+Backspace)">
✕ Verwerfen
</button>
{/if}
</div>

View file

@ -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 {

View file

@ -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;
}
});
</script>
<div class="message-list" bind:this={container} onscroll={checkScroll}>
@ -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); }
}
</style>

View file

@ -94,7 +94,10 @@
{#if phaseText}
<span class="dot-sep">·</span>
<span class="item phase">{phaseText}</span>
<span class="item phase">
<span class="phase-dot"></span>
{phaseText}
</span>
{/if}
<span class="spacer"></span>
@ -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 {

View file

@ -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 @@
<!-- Tool-Card: kompakte „Background-Aktion"-Pille. Bewusst dezenter als
Chat-Messages — kleinerer Font, monospaced, gedämpfte Farben.
Sobald done und nichts Spannendes drin → bleibt collapsed. -->
<div class="tool-card {statusClass(call.status)}" class:open={isOpen}>
<div
class="tool-card {statusClass(call.status)}"
class:open={isOpen}
data-tool-id={call.id}
transition:slide={{ duration: 180, easing: quintOut }}
>
<button class="card-header" onclick={toggle} aria-expanded={isOpen}>
<span class="chevron" class:open={isOpen}></span>
<span class="icon">{meta.icon}</span>
@ -85,11 +92,37 @@
border-left: 2px solid var(--vscode-input-border, #3c3c3c);
opacity: 0.78;
transition: opacity 0.15s ease;
position: relative;
}
.tool-card.running {
opacity: 1;
border-left-color: var(--vscode-progressBar-background, #3794ff);
}
/* Shimmer-Effekt auf laufenden Karten — sanftes Lauflicht ueber den
linken Rand. Suggestiert "lebendige Aktion", aehnlich Codium. */
.tool-card.running::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(
180deg,
transparent 0%,
var(--vscode-progressBar-background, #3794ff) 40%,
var(--vscode-progressBar-background, #3794ff) 60%,
transparent 100%
);
animation: shimmer-slide 1.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes shimmer-slide {
0% { transform: translateY(-100%); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(100%); opacity: 0; }
}
.tool-card.error {
opacity: 1;
border-left-color: var(--vscode-errorForeground, #f48771);

View file

@ -5,7 +5,9 @@
// Claude Code.
import { onMount, onDestroy } from 'svelte';
import { processingPhase, currentTool } from '$lib/stores/events';
// Allgemeine Verben fuer "denken/grübeln" — wenn keine Phase bekannt ist
const VERBS = [
'Denke nach',
'Gruebele',
@ -37,12 +39,29 @@
'Lege Hand an',
];
// Pro Phase ein passender Text — Codium-Style "claude is thinking…"
function phaseVerb(phase: string, tool: string | null): string {
switch (phase) {
case 'thinking': return 'Denkt nach';
case 'streaming': return 'Schreibt';
case 'tool-use': return tool ? `Nutzt ${tool}` : 'Nutzt Tool';
case 'subagent': return 'Subagent arbeitet';
default: return '';
}
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let verb = $state(pickVerb());
let randomVerb = $state(pickVerb());
let spinnerIdx = $state(0);
let elapsed = $state(0);
// Phase-bewusstes Verb: bevorzugt die spezifische Phase, sonst Random.
const verb = $derived.by(() => {
const pv = phaseVerb($processingPhase, $currentTool);
return pv || randomVerb;
});
let verbTimer: ReturnType<typeof setInterval> | null = null;
let spinTimer: ReturnType<typeof setInterval> | null = null;
let secondsTimer: ReturnType<typeof setInterval> | null = null;
@ -60,7 +79,7 @@
onMount(() => {
const start = Date.now();
verbTimer = setInterval(() => { verb = pickVerb(); }, 2500);
verbTimer = setInterval(() => { randomVerb = pickVerb(); }, 2500);
spinTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
}, 90);