feat: Projekt-Wechsel, File-Drop, Persistent Memory [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m45s

- Projekt-Wechsel UI in SessionList: Dropdown, Hinzufügen/Entfernen, auto Sticky-Context
- File-Drop auf Chat: Text als Code-Block, Bilder als Base64, 500KB Limit
- Persistent Memory: auto_load Einträge in Claude-Context injiziert (Cross-Session)
- Memory CRUD Tauri-Commands: save/delete/list/autoload
- Svelte 5 Syntax-Fix: on:click → onclick in SessionList.svelte

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-21 10:55:35 +02:00
parent 6e1a3c41f2
commit 68d2500037
8 changed files with 709 additions and 14 deletions

View file

@ -9,6 +9,10 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] - 2026-04-21 ## [Unreleased] - 2026-04-21
### Hinzugefügt ### Hinzugefügt
- **Projekt-Wechsel**: Ein-Klick-Projektwechsel in der Sidebar — Dropdown mit Projektliste, Hinzufügen/Entfernen, Working-Dir + Sticky-Context wird automatisch umgeschaltet (`SessionList.svelte`, `db.rs`)
- **File-Drop auf Chat**: Dateien per Drag & Drop auf den Chat ziehen — Text-Dateien als Code-Block, Bilder als Base64, Spracherkennung, 500KB-Limit (`ChatPanel.svelte`)
- **Persistent Memory**: Auto-Load Memory-Einträge werden bei jeder Nachricht in den Claude-Context injiziert — Cross-Session Gedächtnis für Patterns, Zugänge, Präferenzen (`memory.rs`, `claude.rs`)
- **Memory CRUD-Commands**: Speichern, Löschen, Auflisten, Auto-Load-Filter für Memory-Einträge als Tauri-Commands (`memory.rs`, `lib.rs`)
- **Quick-Actions Palette (Ctrl+K)**: VS-Code-artige Kommandopalette mit Suche, Kategorien (Build, Git, Session, Navigation, Voice, Tools), Keyboard-Navigation (`QuickActions.svelte`) - **Quick-Actions Palette (Ctrl+K)**: VS-Code-artige Kommandopalette mit Suche, Kategorien (Build, Git, Session, Navigation, Voice, Tools), Keyboard-Navigation (`QuickActions.svelte`)
- **Lokales Voice (Phase 2.2)**: whisper-cli STT + piper-tts TTS, komplett lokal ohne OpenAI-API (`voice.rs`, `VoicePanel.svelte`) - **Lokales Voice (Phase 2.2)**: whisper-cli STT + piper-tts TTS, komplett lokal ohne OpenAI-API (`voice.rs`, `VoicePanel.svelte`)
- **Chat-Detach**: Chat in separates Fenster herauslösen, Platz für andere Panels, Zurückholen per Button (`chat_window.rs`, `+page.svelte`) - **Chat-Detach**: Chat in separates Fenster herauslösen, Platz für andere Panels, Zurückholen per Button (`chat_window.rs`, `+page.svelte`)
@ -33,6 +37,8 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
- `lib.rs`: App-Lifecycle erweitert um Lock-Datei create/remove bei Start/Exit - `lib.rs`: App-Lifecycle erweitert um Lock-Datei create/remove bei Start/Exit
### Behoben ### Behoben
- **Svelte 5 Event-Syntax**: Alle `on:click``onclick` in SessionList.svelte (keine Mixed-Syntax mehr)
- **Mikrofon hängt**: PipeWire-Fallback + 5s getUserMedia-Timeout wenn PipeWire nicht läuft (`nix/default.nix`, `ChatPanel.svelte`)
- **Update-Fortschrittsbalken**: Erreicht jetzt visuell 100% vor der Bestätigungsmeldung (`update.rs`, `UpdateDialog.svelte`) - **Update-Fortschrittsbalken**: Erreicht jetzt visuell 100% vor der Bestätigungsmeldung (`update.rs`, `UpdateDialog.svelte`)
- **Mikrofon in Produktion**: GStreamer + PipeWire-Plugins fehlten im Nix-Wrapper, WebKitGTK konnte getUserMedia nicht nutzen (`nix/default.nix`) - **Mikrofon in Produktion**: GStreamer + PipeWire-Plugins fehlten im Nix-Wrapper, WebKitGTK konnte getUserMedia nicht nutzen (`nix/default.nix`)
- Updater konnte Binary ersetzen während App noch lief (kein Lock, kein Prozess-Check) - Updater konnte Binary ersetzen während App noch lief (kein Lock, kein Prozess-Check)

View file

@ -60,10 +60,10 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
| Feature | Datei(en) | Status | | Feature | Datei(en) | Status |
|---------|-----------|--------| |---------|-----------|--------|
| Projekt-Wechsel | `context.rs`, UI | ⬜ Ein Klick wechselt Projekt (CWD, CLAUDE.md, Context, KB-Filter) | | ✅ Projekt-Wechsel | `db.rs`, `SessionList.svelte` | Ein Klick wechselt Projekt (CWD, Context, KB-Filter) |
| MCP-Hub nativ | `claude-bridge.js` | ⬜ Alle MCP-Server direkt nutzbar (Docker, Forgejo, DB) ohne CLI-Umweg | | MCP-Hub nativ | `claude-bridge.js` | ⬜ Alle MCP-Server direkt nutzbar (Docker, Forgejo, DB) ohne CLI-Umweg |
| Guard-Rails UI | `guard.rs`, `GuardPanel.svelte` | ⬜ Live-Anzeige was Claude darf/nicht darf, Ein-Klick-Freigabe | | Guard-Rails UI | `guard.rs`, `GuardPanel.svelte` | ⬜ Live-Anzeige was Claude darf/nicht darf, Ein-Klick-Freigabe |
| Persistent Memory | `memory.rs`, `db.rs` | ⬜ Cross-Session Gedaechtnis — Claude erinnert sich an ALLES | | ✅ Persistent Memory | `memory.rs`, `claude.rs` | Auto-Load Eintraege in Context, Cross-Session Gedaechtnis |
| ✅ Quick-Actions | `QuickActions.svelte`, `ChatPanel.svelte` | Ctrl+K Palette: Deploy, Build, Test, Commit, Git, Navigation | | ✅ Quick-Actions | `QuickActions.svelte`, `ChatPanel.svelte` | Ctrl+K Palette: Deploy, Build, Test, Commit, Git, Navigation |
| ✅ Voice-Conversation | `voice.rs`, `VoicePanel.svelte` | Lokales Whisper STT + Piper TTS, VAD, Gespraechsmodus | | ✅ Voice-Conversation | `voice.rs`, `VoicePanel.svelte` | Lokales Whisper STT + Piper TTS, VAD, Gespraechsmodus |
| ✅ Settings-Panel | `SettingsPanel.svelte` | VS-Code-artiges Layout mit Suche, Kategorien, Commands, Hooks | | ✅ Settings-Panel | `SettingsPanel.svelte` | VS-Code-artiges Layout mit Suche, Kategorien, Commands, Hooks |
@ -93,7 +93,7 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
|---------|-----------|--------------| |---------|-----------|--------------|
| D-Bus Actions | `programs.rs` | Vordefinierte Aktionen: Dolphin oeffnen, Kate starten, Notifications | | D-Bus Actions | `programs.rs` | Vordefinierte Aktionen: Dolphin oeffnen, Kate starten, Notifications |
| Clipboard-Watch | neu: `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code-Snippet → erklaeren, URL → zusammenfassen) | | Clipboard-Watch | neu: `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code-Snippet → erklaeren, URL → zusammenfassen) |
| File-Drop | `ChatPanel.svelte` | Dateien auf Chat droppen → Claude analysiert/bearbeitet | | File-Drop | `ChatPanel.svelte` | Dateien auf Chat droppen → Claude analysiert/bearbeitet |
| Screenshot-Analyse | `programs.rs` | Bildschirmbereich markieren → Claude beschreibt/debuggt UI | | Screenshot-Analyse | `programs.rs` | Bildschirmbereich markieren → Claude beschreibt/debuggt UI |
| Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall | | Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall |

View file

@ -487,11 +487,28 @@ fn load_sticky_context_for_prompt(app: &AppHandle) -> Option<String> {
} }
} }
// Auto-Load Memory-Einträge anhängen (Persistent Memory)
let memory_entries = db.load_memory_entries().unwrap_or_default();
let autoload: Vec<_> = memory_entries.into_iter().filter(|e| e.auto_load).collect();
let mut memory_section = String::new();
if !autoload.is_empty() {
memory_section.push_str("\n\n## Persistentes Gedächtnis\n");
for entry in &autoload {
memory_section.push_str(&format!("- **{}** ({}): {}\n",
entry.key,
format!("{:?}", entry.category),
entry.value
));
}
println!("🧠 {} Auto-Load Memory-Einträge in Context injiziert", autoload.len());
}
let rendered = sticky.render(); let rendered = sticky.render();
if rendered.is_empty() { let combined = format!("{}{}", rendered, memory_section);
if combined.trim().is_empty() {
None None
} else { } else {
Some(rendered) Some(combined)
} }
} else { } else {
None None

View file

@ -205,6 +205,19 @@ impl Database {
WHERE timestamp < datetime('now', '-7 days'); WHERE timestamp < datetime('now', '-7 days');
END; END;
-- Projekte (für schnellen Wechsel)
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
working_dir TEXT NOT NULL,
claude_md_path TEXT,
description TEXT,
last_used TEXT NOT NULL,
created_at TEXT NOT NULL,
session_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_projects_last_used ON projects(last_used DESC);
-- Phase 2.0: Fehler-Tracking für Auto-Pattern-Erkennung -- Phase 2.0: Fehler-Tracking für Auto-Pattern-Erkennung
CREATE TABLE IF NOT EXISTS error_tracker ( CREATE TABLE IF NOT EXISTS error_tracker (
error_hash TEXT PRIMARY KEY, error_hash TEXT PRIMARY KEY,
@ -401,7 +414,6 @@ impl Database {
} }
/// Löscht einen Memory-Eintrag /// Löscht einen Memory-Eintrag
#[allow(dead_code)]
pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> { pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?; self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?;
Ok(()) Ok(())
@ -1155,3 +1167,123 @@ pub async fn get_error_stats(
let db = state.lock().unwrap(); let db = state.lock().unwrap();
db.get_error_stats(limit).map_err(|e| e.to_string()) db.get_error_stats(limit).map_err(|e| e.to_string())
} }
// ============ Projekte ============
/// Ein Projekt für schnellen Wechsel
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub working_dir: String,
pub claude_md_path: Option<String>,
pub description: Option<String>,
pub last_used: String,
pub created_at: String,
pub session_count: i64,
}
impl Database {
/// Alle Projekte laden (zuletzt benutzte zuerst)
pub fn list_projects(&self) -> SqlResult<Vec<Project>> {
let mut stmt = self.conn.prepare(
"SELECT id, name, working_dir, claude_md_path, description, last_used, created_at, session_count
FROM projects ORDER BY last_used DESC"
)?;
let projects = stmt.query_map([], |row| {
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
working_dir: row.get(2)?,
claude_md_path: row.get(3)?,
description: row.get(4)?,
last_used: row.get(5)?,
created_at: row.get(6)?,
session_count: row.get(7)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(projects)
}
/// Projekt speichern (INSERT oder UPDATE)
pub fn save_project(&self, project: &Project) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO projects (id, name, working_dir, claude_md_path, description, last_used, created_at, session_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
project.id, project.name, project.working_dir,
project.claude_md_path, project.description,
project.last_used, project.created_at, project.session_count,
],
)?;
Ok(())
}
/// Projekt löschen
pub fn delete_project(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
Ok(())
}
/// Projekt als zuletzt benutzt markieren
pub fn touch_project(&self, id: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE projects SET last_used = datetime('now'), session_count = session_count + 1 WHERE id = ?1",
params![id],
)?;
Ok(())
}
}
// Tauri-Commands für Projekte
#[tauri::command]
pub async fn list_projects(app: AppHandle) -> Result<Vec<Project>, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.list_projects().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn save_project(app: AppHandle, project: Project) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.save_project(&project).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_project(app: AppHandle, id: String) -> Result<(), String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.delete_project(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn switch_project(app: AppHandle, project_id: String) -> Result<Project, String> {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
// Projekt als benutzt markieren
db.touch_project(&project_id).map_err(|e| e.to_string())?;
// Projekt-Daten laden
let projects = db.list_projects().map_err(|e| e.to_string())?;
let project = projects.into_iter()
.find(|p| p.id == project_id)
.ok_or_else(|| format!("Projekt '{}' nicht gefunden", project_id))?;
// Sticky-Context aktualisieren: current_project setzen
let project_json = serde_json::json!({
"id": project.id,
"name": project.name,
"working_dir": project.working_dir,
}).to_string();
db.conn.execute(
"INSERT OR REPLACE INTO sticky_context (key, value, priority, updated_at)
VALUES ('project:current', ?1, 10, datetime('now'))",
params![project_json],
).map_err(|e| e.to_string())?;
Ok(project)
}

View file

@ -53,6 +53,10 @@ pub fn run() {
// Gedächtnis-System // Gedächtnis-System
memory::load_memory, memory::load_memory,
memory::get_sticky_memory_entries, memory::get_sticky_memory_entries,
memory::save_memory_entry,
memory::delete_memory_entry,
memory::list_memory_entries,
memory::get_autoload_memory,
memory::save_pattern, memory::save_pattern,
memory::detect_issue, memory::detect_issue,
// Audit-Log // Audit-Log
@ -97,6 +101,11 @@ pub fn run() {
db::track_error, db::track_error,
db::set_error_kb_pattern, db::set_error_kb_pattern,
db::get_error_stats, db::get_error_stats,
// Projekte
db::list_projects,
db::save_project,
db::delete_project,
db::switch_project,
// Wissensbasis (claude-db) // Wissensbasis (claude-db)
knowledge::search_knowledge, knowledge::search_knowledge,
knowledge::get_knowledge, knowledge::get_knowledge,

View file

@ -106,6 +106,43 @@ pub async fn save_pattern(app: AppHandle, pattern: Pattern) -> Result<(), String
db_lock.save_pattern(&pattern).map_err(|e| e.to_string()) db_lock.save_pattern(&pattern).map_err(|e| e.to_string())
} }
/// Speichert einen Memory-Eintrag (von Frontend oder intern)
#[tauri::command]
pub async fn save_memory_entry(app: AppHandle, entry: MemoryEntry) -> Result<(), String> {
println!("🧠 Speichere Memory: {} (sticky={}, auto_load={})", entry.key, entry.sticky, entry.auto_load);
let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
db_lock.save_memory_entry(&entry).map_err(|e| e.to_string())
}
/// Löscht einen Memory-Eintrag
#[tauri::command]
pub async fn delete_memory_entry(app: AppHandle, id: String) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
db_lock.delete_memory_entry(&id).map_err(|e| e.to_string())
}
/// Listet alle Memory-Einträge (für UI)
#[tauri::command]
pub async fn list_memory_entries(app: AppHandle) -> Result<Vec<MemoryEntry>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
db_lock.load_memory_entries().map_err(|e| e.to_string())
}
/// Holt nur auto_load Einträge (für Session-Start Injection)
#[tauri::command]
pub async fn get_autoload_memory(app: AppHandle) -> Result<Vec<MemoryEntry>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db_lock = state.lock().unwrap();
let entries = db_lock.load_memory_entries().map_err(|e| e.to_string())?;
let autoload: Vec<MemoryEntry> = entries.into_iter().filter(|e| e.auto_load).collect();
println!("🧠 {} Auto-Load Einträge für Session-Start", autoload.len());
Ok(autoload)
}
/// Erkennt ein Problem und schlägt Korrektur vor /// Erkennt ein Problem und schlägt Korrektur vor
#[tauri::command] #[tauri::command]
pub async fn detect_issue( pub async fn detect_issue(

View file

@ -222,6 +222,102 @@
// Quick-Actions Palette (Ctrl+K) // Quick-Actions Palette (Ctrl+K)
let showQuickActions = $state(false); let showQuickActions = $state(false);
// File-Drop State
let isDragOver = $state(false);
let dragCounter = 0; // Verhindert Flackern bei verschachtelten Elementen
function handleDragEnter(e: DragEvent) {
e.preventDefault();
dragCounter++;
if (e.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
isDragOver = false;
dragCounter = 0;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
dragCounter = 0;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const fileContents: string[] = [];
for (const file of Array.from(files)) {
try {
// Bilder: als Base64 einbetten (Claude kann Bilder analysieren)
if (file.type.startsWith('image/')) {
const base64 = await fileToBase64(file);
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}, ${file.type})\n\n![${file.name}](${base64})`);
continue;
}
// Textdateien: Inhalt lesen
if (file.size > 500_000) {
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}) — Datei zu groß für direkten Chat (max 500KB). Bitte über Dateipfad referenzieren.`);
continue;
}
const text = await file.text();
const ext = file.name.split('.').pop() || '';
const lang = extToLang(ext);
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)})\n\`\`\`${lang}\n${text}\n\`\`\``);
} catch (err) {
fileContents.push(`📎 **${file.name}** — Fehler beim Lesen: ${err}`);
}
}
if (fileContents.length > 0) {
const plural = files.length > 1 ? `${files.length} Dateien` : 'Datei';
const prefix = `Ich habe dir ${plural} in den Chat gezogen. Analysiere bitte:\n\n`;
$currentInput = prefix + fileContents.join('\n\n---\n\n');
inputTextarea?.focus();
}
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function extToLang(ext: string): string {
const map: Record<string, string> = {
ts: 'typescript', js: 'javascript', py: 'python', rs: 'rust',
svelte: 'svelte', html: 'html', css: 'css', json: 'json',
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown',
sh: 'bash', sql: 'sql', php: 'php', nix: 'nix', xml: 'xml',
vue: 'vue', tsx: 'tsx', jsx: 'jsx', go: 'go', java: 'java',
};
return map[ext.toLowerCase()] || ext;
}
// Slash-Command Autocomplete State // Slash-Command Autocomplete State
let showCommandPalette = $state(false); let showCommandPalette = $state(false);
let commandQuery = $state(''); let commandQuery = $state('');
@ -923,7 +1019,21 @@
} }
</script> </script>
<div class="chat-panel"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="chat-panel" class:drag-over={isDragOver}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}>
{#if isDragOver}
<div class="drop-overlay">
<div class="drop-icon">📎</div>
<p>Dateien hier ablegen</p>
<p class="drop-hint">Code, Text, Bilder — Claude analysiert alles</p>
</div>
{/if}
<div class="chat-header"> <div class="chat-header">
<h2>💬 Chat</h2> <h2>💬 Chat</h2>
<div class="header-stats"> <div class="header-stats">
@ -2195,4 +2305,49 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-top: var(--spacing-sm); margin-top: var(--spacing-sm);
} }
/* File-Drop Overlay */
.chat-panel.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
.drop-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(124, 58, 237, 0.12);
backdrop-filter: blur(4px);
border-radius: inherit;
pointer-events: none;
}
.drop-icon {
font-size: 3rem;
animation: dropBounce 0.5s ease;
}
@keyframes dropBounce {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
.drop-overlay p {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--accent);
}
.drop-hint {
font-size: 0.75rem !important;
font-weight: 400 !important;
color: var(--text-secondary) !important;
}
</style> </style>

View file

@ -19,12 +19,94 @@
last_message: string | null; last_message: string | null;
} }
interface Project {
id: string;
name: string;
working_dir: string;
claude_md_path: string | null;
description: string | null;
last_used: string;
created_at: string;
session_count: number;
}
let sessions: Session[] = []; let sessions: Session[] = [];
let activeSessionId: string | null = null; let activeSessionId: string | null = null;
let loading = true; let loading = true;
let showNewForm = false; let showNewForm = false;
let newTitle = ''; let newTitle = '';
// Projekt-Wechsel
let projects: Project[] = [];
let activeProject: Project | null = null;
let showProjectAdd = false;
let newProjectName = '';
let newProjectDir = '';
let showProjectList = false;
async function loadProjects() {
try {
projects = await invoke('list_projects');
console.log(`📁 ${projects.length} Projekte geladen`);
} catch (err) {
console.error('Fehler beim Laden der Projekte:', err);
}
}
async function addProject() {
const name = newProjectName.trim();
const dir = newProjectDir.trim();
if (!name || !dir) return;
const project: Project = {
id: crypto.randomUUID(),
name,
working_dir: dir,
claude_md_path: null,
description: null,
last_used: new Date().toISOString(),
created_at: new Date().toISOString(),
session_count: 0,
};
try {
await invoke('save_project', { project });
newProjectName = '';
newProjectDir = '';
showProjectAdd = false;
await loadProjects();
// Direkt zum neuen Projekt wechseln
await switchProject(project.id);
} catch (err) {
console.error('Fehler beim Erstellen des Projekts:', err);
}
}
async function switchProject(projectId: string) {
try {
const project: Project = await invoke('switch_project', { projectId });
activeProject = project;
showProjectList = false;
console.log(`📂 Projekt gewechselt: ${project.name} → ${project.working_dir}`);
// Sessions neu laden (gefiltert nach Projekt wäre Zukunftsmusik)
await loadSessions();
} catch (err) {
console.error('Fehler beim Projektwechsel:', err);
}
}
async function removeProject(id: string) {
try {
await invoke('delete_project', { id });
if (activeProject?.id === id) {
activeProject = null;
}
await loadProjects();
} catch (err) {
console.error('Fehler beim Löschen des Projekts:', err);
}
}
async function loadSessions() { async function loadSessions() {
try { try {
sessions = await invoke('list_sessions', { limit: 50 }); sessions = await invoke('list_sessions', { limit: 50 });
@ -55,6 +137,7 @@
let sessionCreatedListener: UnlistenFn | null = null; let sessionCreatedListener: UnlistenFn | null = null;
onMount(async () => { onMount(async () => {
await loadProjects();
loadSessions(); loadSessions();
// Auf Auto-Session-Erstellung vom ChatPanel hören // Auf Auto-Session-Erstellung vom ChatPanel hören
sessionCreatedListener = await listen('session-created', () => { sessionCreatedListener = await listen('session-created', () => {
@ -72,7 +155,7 @@
try { try {
const session: Session = await invoke('create_session', { const session: Session = await invoke('create_session', {
title, title,
workingDir: null, workingDir: activeProject?.working_dir || null,
}); });
activeSessionId = session.id; activeSessionId = session.id;
$currentSessionId = session.id; $currentSessionId = session.id;
@ -129,12 +212,88 @@
if (usd < 0.01) return `${(usd * 100).toFixed(1)}¢`; if (usd < 0.01) return `${(usd * 100).toFixed(1)}¢`;
return `${usd.toFixed(2).replace('.', ',')}$`; return `${usd.toFixed(2).replace('.', ',')}$`;
} }
function shortenPath(path: string): string {
// /mnt/17 - Entwicklungen/20 - Projekte/Foo → .../Foo
const parts = path.split('/');
if (parts.length > 3) return `.../${parts.slice(-1)[0]}`;
return path;
}
</script> </script>
<div class="session-list"> <div class="session-list">
<!-- Projekt-Wechsel Bereich -->
<div class="project-bar">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="project-current" onclick={() => showProjectList = !showProjectList}>
<span class="project-icon">📁</span>
{#if activeProject}
<span class="project-name">{activeProject.name}</span>
<span class="project-path">{shortenPath(activeProject.working_dir)}</span>
{:else}
<span class="project-name no-project">Kein Projekt</span>
{/if}
<span class="project-chevron">{showProjectList ? '▲' : '▼'}</span>
</div>
{#if showProjectList}
<div class="project-dropdown">
{#each projects as project}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="project-item"
class:active={activeProject?.id === project.id}
onclick={() => switchProject(project.id)}
>
<div class="project-item-info">
<span class="project-item-name">{project.name}</span>
<span class="project-item-path">{shortenPath(project.working_dir)}</span>
</div>
<div class="project-item-actions">
<span class="project-item-count">{project.session_count}</span>
<button
class="btn-project-delete"
onclick={(e: MouseEvent) => { e.stopPropagation(); removeProject(project.id); }}
title="Projekt entfernen"
>✕</button>
</div>
</div>
{/each}
{#if !showProjectAdd}
<button class="btn-project-add" onclick={() => showProjectAdd = true}>
+ Projekt hinzufügen
</button>
{:else}
<div class="project-add-form">
<input
type="text"
bind:value={newProjectName}
placeholder="Projektname"
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && addProject()}
/>
<input
type="text"
bind:value={newProjectDir}
placeholder="Verzeichnis (/mnt/...)"
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && addProject()}
/>
<div class="project-add-buttons">
<button class="btn-create" onclick={addProject}>Anlegen</button>
<button class="btn-cancel" onclick={() => { showProjectAdd = false; newProjectName = ''; newProjectDir = ''; }}>Abbrechen</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Sessions Header -->
<div class="session-header"> <div class="session-header">
<h2>💬 Sessions</h2> <h2>💬 Sessions</h2>
<button class="btn-new" on:click={() => showNewForm = !showNewForm}> <button class="btn-new" onclick={() => showNewForm = !showNewForm}>
{showNewForm ? '✕' : '+ Neu'} {showNewForm ? '✕' : '+ Neu'}
</button> </button>
</div> </div>
@ -145,9 +304,9 @@
type="text" type="text"
bind:value={newTitle} bind:value={newTitle}
placeholder="Session-Titel (optional)" placeholder="Session-Titel (optional)"
on:keydown={(e) => e.key === 'Enter' && createSession()} onkeydown={(e) => e.key === 'Enter' && createSession()}
/> />
<button class="btn-create" on:click={createSession}>Erstellen</button> <button class="btn-create" onclick={createSession}>Erstellen</button>
</div> </div>
{/if} {/if}
@ -156,7 +315,7 @@
{:else if sessions.length === 0} {:else if sessions.length === 0}
<div class="empty"> <div class="empty">
<p>Keine Sessions vorhanden.</p> <p>Keine Sessions vorhanden.</p>
<button class="btn-first" on:click={() => { showNewForm = true; }}> <button class="btn-first" onclick={() => { showNewForm = true; }}>
Erste Session starten Erste Session starten
</button> </button>
</div> </div>
@ -168,7 +327,7 @@
<div <div
class="session-item" class="session-item"
class:active={session.id === activeSessionId} class:active={session.id === activeSessionId}
on:click={() => resumeSession(session.id)} onclick={() => resumeSession(session.id)}
> >
<div class="session-main"> <div class="session-main">
<span class="session-status"> <span class="session-status">
@ -194,7 +353,7 @@
<span class="session-time">{formatDate(session.updated_at)}</span> <span class="session-time">{formatDate(session.updated_at)}</span>
<button <button
class="btn-delete" class="btn-delete"
on:click|stopPropagation={() => deleteSession(session.id)} onclick={(e: MouseEvent) => { e.stopPropagation(); deleteSession(session.id); }}
title="Session löschen" title="Session löschen"
> >
🗑️ 🗑️
@ -215,6 +374,186 @@
background: var(--bg-primary); background: var(--bg-primary);
} }
/* Projekt-Wechsel */
.project-bar {
position: relative;
border-bottom: 1px solid var(--border);
}
.project-current {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
cursor: pointer;
transition: background 0.15s;
min-height: 36px;
}
.project-current:hover {
background: var(--bg-hover);
}
.project-icon {
font-size: 0.75rem;
flex-shrink: 0;
}
.project-name {
font-size: 0.75rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.project-name.no-project {
color: var(--text-secondary);
font-style: italic;
font-weight: 400;
}
.project-path {
font-size: 0.6rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100px;
}
.project-chevron {
font-size: 0.55rem;
color: var(--text-secondary);
flex-shrink: 0;
}
.project-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-top: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
max-height: 300px;
overflow-y: auto;
}
.project-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--bg-tertiary);
}
.project-item:hover {
background: var(--bg-hover);
}
.project-item.active {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
}
.project-item-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.project-item-name {
font-size: 0.75rem;
font-weight: 500;
}
.project-item-path {
font-size: 0.6rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-item-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.project-item-count {
font-size: 0.6rem;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 1px 5px;
border-radius: var(--radius-sm);
}
.btn-project-delete {
font-size: 0.6rem;
opacity: 0;
padding: 2px 4px;
color: var(--text-secondary);
transition: opacity 0.15s;
}
.project-item:hover .btn-project-delete {
opacity: 0.5;
}
.btn-project-delete:hover {
opacity: 1 !important;
color: var(--error);
}
.btn-project-add {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
font-size: 0.7rem;
color: var(--accent);
transition: background 0.15s;
}
.btn-project-add:hover {
background: var(--bg-hover);
}
.project-add-form {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
border-top: 1px solid var(--bg-tertiary);
}
.project-add-form input {
font-size: 0.7rem;
padding: var(--spacing-xs) var(--spacing-sm);
}
.project-add-buttons {
display: flex;
gap: var(--spacing-xs);
}
.btn-cancel {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: var(--radius-sm);
font-size: 0.65rem;
}
.session-header { .session-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;