fix: UTF-8-Crash + Input-Reset + ApprovalBar + Scroll/Streaming-Polish [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m20s
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:
parent
75a93987fe
commit
79f4f9fb21
17 changed files with 705 additions and 53 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -8,6 +8,19 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||||
|
|
||||||
## [Unreleased] - 2026-04-27
|
## [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)
|
### 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
|
- **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
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::knowledge;
|
use crate::knowledge;
|
||||||
|
use crate::strutil::safe_truncate;
|
||||||
|
|
||||||
/// Standard-Pfade für UDS-Daemon
|
/// Standard-Pfade für UDS-Daemon
|
||||||
const SOCKET_PATH: &str = "/tmp/claude-bridge.sock";
|
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) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
match serde_json::from_str::<BridgeMessage>(&line) {
|
match serde_json::from_str::<BridgeMessage>(&line) {
|
||||||
Ok(msg) => handle_bridge_message(&app_handle, msg),
|
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...");
|
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) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
match serde_json::from_str::<BridgeMessage>(&line) {
|
match serde_json::from_str::<BridgeMessage>(&line) {
|
||||||
Ok(msg) => handle_bridge_message(&app_handle, msg),
|
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");
|
println!("⚠️ Bridge stdout geschlossen");
|
||||||
|
|
@ -657,7 +658,7 @@ fn send_to_bridge_full(
|
||||||
/// Nachricht an Claude senden
|
/// Nachricht an Claude senden
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_message(app: AppHandle, message: String) -> Result<String, String> {
|
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
|
// Bridge starten falls nicht aktiv
|
||||||
let needs_start = {
|
let needs_start = {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use tauri::{AppHandle, Manager};
|
||||||
use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats};
|
use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats};
|
||||||
use crate::guard::{Permission, PermissionAction, PermissionType};
|
use crate::guard::{Permission, PermissionAction, PermissionType};
|
||||||
use crate::memory::{ContextCategory, MemoryEntry, Pattern};
|
use crate::memory::{ContextCategory, MemoryEntry, Pattern};
|
||||||
|
use crate::strutil::safe_truncate_ellipsis;
|
||||||
|
|
||||||
/// Eine Claude-Session
|
/// Eine Claude-Session
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -798,7 +799,7 @@ impl Database {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
session_id: row.get(1)?,
|
session_id: row.get(1)?,
|
||||||
role: row.get(2)?,
|
role: row.get(2)?,
|
||||||
snippet: if content.len() > 240 { format!("{}…", &content[..240]) } else { content },
|
snippet: safe_truncate_ellipsis(&content, 240),
|
||||||
timestamp: row.get(4)?,
|
timestamp: row.get(4)?,
|
||||||
session_title: row.get(5)?,
|
session_title: row.get(5)?,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::strutil::safe_truncate;
|
||||||
|
|
||||||
// ============ KB-Cache (RAM) ============
|
// ============ KB-Cache (RAM) ============
|
||||||
// Cached KB-Suchergebnisse im RAM. Spart MySQL-Roundtrip pro Nachricht.
|
// Cached KB-Suchergebnisse im RAM. Spart MySQL-Roundtrip pro Nachricht.
|
||||||
// Invalidierung: automatisch nach 60 Sekunden (TTL).
|
// 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);
|
let start = topic.keywords.len().saturating_sub(6);
|
||||||
topic.keywords[start..].join(" ")
|
topic.keywords[start..].join(" ")
|
||||||
} else if new_keywords.is_empty() {
|
} else if new_keywords.is_empty() {
|
||||||
query[..query.len().min(100)].to_string()
|
safe_truncate(query, 100).to_string()
|
||||||
} else {
|
} else {
|
||||||
new_keywords.join(" ")
|
new_keywords.join(" ")
|
||||||
};
|
};
|
||||||
|
|
@ -472,7 +474,7 @@ async fn search_knowledge_filtered(search_query: &str, limit: usize, project: &O
|
||||||
|
|
||||||
let block = hints.join("\n");
|
let block = hints.join("\n");
|
||||||
println!("🔍 Smart Hints v2 für '{}': {} Treffer, {} Bytes",
|
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)
|
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();
|
let cache = KB_CACHE.lock().unwrap();
|
||||||
if let Some(cached) = cache.get(&cache_key) {
|
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());
|
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;
|
let _ = pool.disconnect().await;
|
||||||
|
|
||||||
if results.is_empty() {
|
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
|
// Leere Ergebnisse auch cachen — verhindert wiederholte DB-Abfragen
|
||||||
let mut cache = KB_CACHE.lock().unwrap();
|
let mut cache = KB_CACHE.lock().unwrap();
|
||||||
cache.insert(cache_key, String::new());
|
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");
|
let block = hints.join("\n");
|
||||||
println!("🔍 KB-Hints für '{}': {} Treffer, {} Bytes (cached)",
|
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
|
// 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());
|
search_terms.push("fehler workaround aktiv".to_string());
|
||||||
|
|
||||||
let query = search_terms.join(" ");
|
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
|
search_knowledge_by_query(&query, 5).await
|
||||||
}
|
}
|
||||||
|
|
@ -632,7 +634,7 @@ pub async fn save_error_pattern_to_kb(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neuen KB-Eintrag erstellen
|
// 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!(
|
let content = format!(
|
||||||
"## Automatisch erkanntes Fehler-Pattern\n\n\
|
"## Automatisch erkanntes Fehler-Pattern\n\n\
|
||||||
**Tool:** {}\n\
|
**Tool:** {}\n\
|
||||||
|
|
@ -641,7 +643,7 @@ pub async fn save_error_pattern_to_kb(
|
||||||
**Hash:** `{}`\n\n\
|
**Hash:** `{}`\n\n\
|
||||||
> Dieses Pattern wurde automatisch erstellt nachdem der Fehler {}x aufgetreten ist.\n\
|
> Dieses Pattern wurde automatisch erstellt nachdem der Fehler {}x aufgetreten ist.\n\
|
||||||
> Bitte Lösung/Workaround ergänzen.",
|
> 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);
|
let tags = format!("auto-pattern,fehler,{},{}", tool.to_lowercase(), error_hash);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ mod knowledge;
|
||||||
mod memory;
|
mod memory;
|
||||||
mod programs;
|
mod programs;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod strutil;
|
||||||
mod teaching;
|
mod teaching;
|
||||||
mod update;
|
mod update;
|
||||||
mod voice;
|
mod voice;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::strutil::safe_truncate;
|
||||||
|
|
||||||
/// Kategorien für Sticky Context (werden nie vergessen)
|
/// Kategorien für Sticky Context (werden nie vergessen)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|
@ -150,8 +151,7 @@ pub async fn detect_issue(
|
||||||
error_message: String,
|
error_message: String,
|
||||||
_context: String,
|
_context: String,
|
||||||
) -> Result<Option<Pattern>, String> {
|
) -> Result<Option<Pattern>, String> {
|
||||||
let preview_len = error_message.len().min(50);
|
println!("🔍 Prüfe auf bekannte Probleme: {}", safe_truncate(&error_message, 50));
|
||||||
println!("🔍 Prüfe auf bekannte Probleme: {}", &error_message[..preview_len]);
|
|
||||||
|
|
||||||
let state = app.state::<Arc<Mutex<db::Database>>>();
|
let state = app.state::<Arc<Mutex<db::Database>>>();
|
||||||
let db_lock = state.lock().unwrap();
|
let db_lock = state.lock().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use crate::claude;
|
use crate::claude;
|
||||||
use crate::db::{self, Session};
|
use crate::db::{self, Session};
|
||||||
|
use crate::strutil::safe_truncate;
|
||||||
|
|
||||||
// ============ Tauri Commands ============
|
// ============ Tauri Commands ============
|
||||||
|
|
||||||
|
|
@ -235,7 +236,7 @@ pub async fn queue_message(
|
||||||
rusqlite::params![queued.id, queued.message, queued.session_id, queued.created_at],
|
rusqlite::params![queued.id, queued.message, queued.session_id, queued.created_at],
|
||||||
).map_err(|e| e.to_string())?;
|
).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);
|
let _ = app.emit("message-queued", &queued);
|
||||||
|
|
||||||
Ok(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",
|
"DELETE FROM offline_queue WHERE id = ?1",
|
||||||
rusqlite::params![msg.id],
|
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) => {
|
Err(e) => {
|
||||||
println!("⚠️ Queue-Nachricht fehlgeschlagen: {} — Abbruch", e);
|
println!("⚠️ Queue-Nachricht fehlgeschlagen: {} — Abbruch", e);
|
||||||
|
|
|
||||||
71
src-tauri/src/strutil.rs
Normal file
71
src-tauri/src/strutil.rs
Normal 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ä");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,11 @@ pub fn create_lock_file() {
|
||||||
content.trim()
|
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
|
// PID in Lock-Datei schreiben
|
||||||
|
|
|
||||||
396
src/lib/components/ApprovalBar.svelte
Normal file
396
src/lib/components/ApprovalBar.svelte
Normal 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>
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import QuickActions from './QuickActions.svelte';
|
import QuickActions from './QuickActions.svelte';
|
||||||
import MessageList from './MessageList.svelte';
|
import MessageList from './MessageList.svelte';
|
||||||
import ConversationBanner from './ConversationBanner.svelte';
|
import ConversationBanner from './ConversationBanner.svelte';
|
||||||
|
import ApprovalBar from './ApprovalBar.svelte';
|
||||||
import { startConversation, stopConversation, conversationActive } from '$lib/voice/conversationEngine';
|
import { startConversation, stopConversation, conversationActive } from '$lib/voice/conversationEngine';
|
||||||
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
|
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
|
||||||
|
|
||||||
|
|
@ -830,6 +831,19 @@
|
||||||
const text = $currentInput.trim();
|
const text = $currentInput.trim();
|
||||||
if (!text) return;
|
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.
|
// Nachricht IMMER sofort senden — auch während Claude arbeitet.
|
||||||
// Die Bridge puffert die Nachricht intern und verarbeitet sie
|
// Die Bridge puffert die Nachricht intern und verarbeitet sie
|
||||||
// automatisch nach dem aktuellen Turn (wie in Claude Code/VS Code Extension).
|
// automatisch nach dem aktuellen Turn (wie in Claude Code/VS Code Extension).
|
||||||
|
|
@ -888,8 +902,6 @@
|
||||||
messages.update((msgs) => [...msgs, msg]);
|
messages.update((msgs) => [...msgs, msg]);
|
||||||
await saveMessageToDb(msg);
|
await saveMessageToDb(msg);
|
||||||
|
|
||||||
$currentInput = '';
|
|
||||||
|
|
||||||
// isProcessing nur setzen wenn nicht schon aktiv
|
// isProcessing nur setzen wenn nicht schon aktiv
|
||||||
// (bei gepufferten Nachrichten laeuft Claude ja schon)
|
// (bei gepufferten Nachrichten laeuft Claude ja schon)
|
||||||
if (!$isProcessing) {
|
if (!$isProcessing) {
|
||||||
|
|
@ -1199,10 +1211,10 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Pending-Changes-Block entfernt (Phase 8): DiffView wird jetzt inline
|
<!-- Sticky Approval-Bar (Phase 9.1): bleibt sichtbar wenn der Chat scrollt,
|
||||||
in ToolCardEdit innerhalb der Assistant-Message gerendert. Backend-Logik
|
der User verliert offene Datei-Aenderungen nicht aus den Augen. Die
|
||||||
(acceptChange/rejectChange) bleibt in dieser Datei und wird ueber
|
eigentliche Diff-Vorschau bleibt inline in der Tool-Karte. -->
|
||||||
den pendingChanges-Store von ToolCardEdit aufgerufen. -->
|
<ApprovalBar />
|
||||||
|
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|
|
||||||
|
|
@ -186,11 +186,11 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if interactive}
|
{#if interactive}
|
||||||
<button class="btn-accept" onclick={handleAccept} title="Aenderung behalten">
|
<button class="btn-accept" onclick={handleAccept} title="Diese Aenderung auf die Datei anwenden (Ctrl+Enter)">
|
||||||
✅ Behalten
|
✓ Übernehmen
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-reject" onclick={handleReject} title="Aenderung rueckgaengig machen">
|
<button class="btn-reject" onclick={handleReject} title="Aenderung verwerfen, Datei bleibt unveraendert (Ctrl+Backspace)">
|
||||||
↩️ Zurueck
|
✕ Verwerfen
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -375,12 +375,18 @@
|
||||||
|
|
||||||
.cursor {
|
.cursor {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: blink 1s steps(2, start) infinite;
|
color: var(--vscode-progressBar-background, var(--accent, #007acc));
|
||||||
color: var(--vscode-progressBar-background);
|
margin-left: 2px;
|
||||||
margin-left: 1px;
|
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 {
|
@keyframes caret-pulse {
|
||||||
to { visibility: hidden; }
|
0%, 100% { opacity: 0.25; transform: scaleY(1); }
|
||||||
|
45% { opacity: 1; transform: scaleY(1.05); }
|
||||||
|
50% { opacity: 1; transform: scaleY(1.05); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-calls {
|
.tool-calls {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@
|
||||||
// Smart-Sticky-Scroll: Wenn der User selbst gescrollt hat, springt das
|
// Smart-Sticky-Scroll: Wenn der User selbst gescrollt hat, springt das
|
||||||
// Auto-Scroll nicht mehr zum Ende. Stattdessen erscheint ein
|
// Auto-Scroll nicht mehr zum Ende. Stattdessen erscheint ein
|
||||||
// Back-to-Bottom-Button.
|
// 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 { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
|
||||||
import { tick } from 'svelte';
|
|
||||||
import MessageItem from './Message.svelte';
|
import MessageItem from './Message.svelte';
|
||||||
import WorkingIndicator from './WorkingIndicator.svelte';
|
import WorkingIndicator from './WorkingIndicator.svelte';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
streamingMessageId?: string | null;
|
streamingMessageId?: string | null;
|
||||||
|
|
@ -26,7 +30,8 @@
|
||||||
|
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let userScrolledUp = $state(false);
|
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
|
// Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine
|
||||||
// Assistant-Tokens da sind (vor erstem Stream + waehrend Tool-Calls).
|
// Assistant-Tokens da sind (vor erstem Stream + waehrend Tool-Calls).
|
||||||
|
|
@ -39,20 +44,30 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkScroll() {
|
function checkScroll() {
|
||||||
if (!container) return;
|
if (!container || autoScrolling) return;
|
||||||
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
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;
|
if (next !== userScrolledUp) userScrolledUp = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToBottom(force = false) {
|
function scrollToBottom(force = false) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (!force && userScrolledUp) return;
|
if (!force && userScrolledUp) return;
|
||||||
if (scrollPending) return;
|
autoScrolling = true;
|
||||||
scrollPending = true;
|
// Smooth nur bei kleinen Distanzen — bei grossem Stream-Catch-up wuerde
|
||||||
await tick();
|
// das die Anzeige ausbremsen, also dort instant.
|
||||||
if (container) container.scrollTop = container.scrollHeight;
|
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
scrollPending = false;
|
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() {
|
function snapToBottom() {
|
||||||
|
|
@ -60,17 +75,70 @@
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-Scroll bei neuen Messages oder neuem Streaming-Token
|
// Reactive-Tracker: deckt jetzt auch Tool-Calls ab. Sobald sich die
|
||||||
// (untracked() verhindert dass userScrolledUp-Aenderungen erneut feuern)
|
// Anzahl Tool-Calls in der letzten Message aendert (Slide-In, Status
|
||||||
|
// running→done), wird Auto-Scroll getriggert.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Nur diese beiden als Dependencies tracken
|
const last = $messages[$messages.length - 1];
|
||||||
const _msgs = $messages.length;
|
const _trackers = [
|
||||||
const _proc = $isProcessing;
|
$messages.length,
|
||||||
// Letzten Content-Length tracken, damit Streaming-Updates feuern
|
$isProcessing,
|
||||||
const _lastLen = $messages[$messages.length - 1]?.content?.length ?? 0;
|
last?.content?.length ?? 0,
|
||||||
void _msgs; void _proc; void _lastLen;
|
last?.toolCalls?.length ?? 0,
|
||||||
|
last?.toolCalls?.map((t) => t.status).join(',') ?? '',
|
||||||
|
];
|
||||||
|
void _trackers;
|
||||||
scrollToBottom();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="message-list" bind:this={container} onscroll={checkScroll}>
|
<div class="message-list" bind:this={container} onscroll={checkScroll}>
|
||||||
|
|
@ -111,6 +179,7 @@
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: var(--vscode-editor-background);
|
background: var(--vscode-editor-background);
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
scroll-behavior: auto; /* smooth wird per JS gesteuert */
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|
@ -144,6 +213,12 @@
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
|
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
animation: bounce-in 220ms ease-out;
|
||||||
}
|
}
|
||||||
.scroll-bottom:hover { background: var(--vscode-button-hoverBackground); }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,10 @@
|
||||||
|
|
||||||
{#if phaseText}
|
{#if phaseText}
|
||||||
<span class="dot-sep">·</span>
|
<span class="dot-sep">·</span>
|
||||||
<span class="item phase">{phaseText}</span>
|
<span class="item phase">
|
||||||
|
<span class="phase-dot"></span>
|
||||||
|
{phaseText}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
@ -170,6 +173,19 @@
|
||||||
font-style: italic;
|
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; }
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
|
|
||||||
import { getToolMeta, getToolSubtitle } from '$lib/utils/toolCards';
|
import { getToolMeta, getToolSubtitle } from '$lib/utils/toolCards';
|
||||||
import type { InlineToolCall } from '$lib/stores';
|
import type { InlineToolCall } from '$lib/stores';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
call: InlineToolCall;
|
call: InlineToolCall;
|
||||||
|
|
@ -49,7 +51,12 @@
|
||||||
<!-- Tool-Card: kompakte „Background-Aktion"-Pille. Bewusst dezenter als
|
<!-- Tool-Card: kompakte „Background-Aktion"-Pille. Bewusst dezenter als
|
||||||
Chat-Messages — kleinerer Font, monospaced, gedämpfte Farben.
|
Chat-Messages — kleinerer Font, monospaced, gedämpfte Farben.
|
||||||
Sobald done und nichts Spannendes drin → bleibt collapsed. -->
|
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}>
|
<button class="card-header" onclick={toggle} aria-expanded={isOpen}>
|
||||||
<span class="chevron" class:open={isOpen}>›</span>
|
<span class="chevron" class:open={isOpen}>›</span>
|
||||||
<span class="icon">{meta.icon}</span>
|
<span class="icon">{meta.icon}</span>
|
||||||
|
|
@ -85,11 +92,37 @@
|
||||||
border-left: 2px solid var(--vscode-input-border, #3c3c3c);
|
border-left: 2px solid var(--vscode-input-border, #3c3c3c);
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.tool-card.running {
|
.tool-card.running {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--vscode-progressBar-background, #3794ff);
|
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 {
|
.tool-card.error {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-left-color: var(--vscode-errorForeground, #f48771);
|
border-left-color: var(--vscode-errorForeground, #f48771);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
// Claude Code.
|
// Claude Code.
|
||||||
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
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 = [
|
const VERBS = [
|
||||||
'Denke nach',
|
'Denke nach',
|
||||||
'Gruebele',
|
'Gruebele',
|
||||||
|
|
@ -37,12 +39,29 @@
|
||||||
'Lege Hand an',
|
'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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
let verb = $state(pickVerb());
|
let randomVerb = $state(pickVerb());
|
||||||
let spinnerIdx = $state(0);
|
let spinnerIdx = $state(0);
|
||||||
let elapsed = $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 verbTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let spinTimer: ReturnType<typeof setInterval> | null = null;
|
let spinTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let secondsTimer: ReturnType<typeof setInterval> | null = null;
|
let secondsTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -60,7 +79,7 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
verbTimer = setInterval(() => { verb = pickVerb(); }, 2500);
|
verbTimer = setInterval(() => { randomVerb = pickVerb(); }, 2500);
|
||||||
spinTimer = setInterval(() => {
|
spinTimer = setInterval(() => {
|
||||||
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
|
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
|
||||||
}, 90);
|
}, 90);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue