feat: Projekt-Wechsel, File-Drop, Persistent Memory [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m45s
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:
parent
6e1a3c41f2
commit
68d2500037
8 changed files with 709 additions and 14 deletions
|
|
@ -9,6 +9,10 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||
## [Unreleased] - 2026-04-21
|
||||
|
||||
### 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`)
|
||||
- **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`)
|
||||
|
|
@ -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
|
||||
|
||||
### 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`)
|
||||
- **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)
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
|
|||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| ✅ 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 |
|
||||
|
|
@ -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 |
|
||||
| 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 |
|
||||
| Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall |
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
if rendered.is_empty() {
|
||||
let combined = format!("{}{}", rendered, memory_section);
|
||||
if combined.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rendered)
|
||||
Some(combined)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -205,6 +205,19 @@ impl Database {
|
|||
WHERE timestamp < datetime('now', '-7 days');
|
||||
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
|
||||
CREATE TABLE IF NOT EXISTS error_tracker (
|
||||
error_hash TEXT PRIMARY KEY,
|
||||
|
|
@ -401,7 +414,6 @@ impl Database {
|
|||
}
|
||||
|
||||
/// Löscht einen Memory-Eintrag
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_memory_entry(&self, id: &str) -> SqlResult<()> {
|
||||
self.conn.execute("DELETE FROM memory WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
|
|
@ -1155,3 +1167,123 @@ pub async fn get_error_stats(
|
|||
let db = state.lock().unwrap();
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ pub fn run() {
|
|||
// Gedächtnis-System
|
||||
memory::load_memory,
|
||||
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::detect_issue,
|
||||
// Audit-Log
|
||||
|
|
@ -97,6 +101,11 @@ pub fn run() {
|
|||
db::track_error,
|
||||
db::set_error_kb_pattern,
|
||||
db::get_error_stats,
|
||||
// Projekte
|
||||
db::list_projects,
|
||||
db::save_project,
|
||||
db::delete_project,
|
||||
db::switch_project,
|
||||
// Wissensbasis (claude-db)
|
||||
knowledge::search_knowledge,
|
||||
knowledge::get_knowledge,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[tauri::command]
|
||||
pub async fn detect_issue(
|
||||
|
|
|
|||
|
|
@ -222,6 +222,102 @@
|
|||
// Quick-Actions Palette (Ctrl+K)
|
||||
let showQuickActions = $state(false);
|
||||
|
||||
// File-Drop State
|
||||
let isDragOver = $state(false);
|
||||
let dragCounter = 0; // Verhindert Flackern bei verschachtelten Elementen
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
isDragOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
isDragOver = false;
|
||||
dragCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = false;
|
||||
dragCounter = 0;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const fileContents: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
// Bilder: als Base64 einbetten (Claude kann Bilder analysieren)
|
||||
if (file.type.startsWith('image/')) {
|
||||
const base64 = await fileToBase64(file);
|
||||
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}, ${file.type})\n\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Textdateien: Inhalt lesen
|
||||
if (file.size > 500_000) {
|
||||
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)}) — Datei zu groß für direkten Chat (max 500KB). Bitte über Dateipfad referenzieren.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
const lang = extToLang(ext);
|
||||
fileContents.push(`📎 **${file.name}** (${formatSize(file.size)})\n\`\`\`${lang}\n${text}\n\`\`\``);
|
||||
} catch (err) {
|
||||
fileContents.push(`📎 **${file.name}** — Fehler beim Lesen: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileContents.length > 0) {
|
||||
const plural = files.length > 1 ? `${files.length} Dateien` : 'Datei';
|
||||
const prefix = `Ich habe dir ${plural} in den Chat gezogen. Analysiere bitte:\n\n`;
|
||||
$currentInput = prefix + fileContents.join('\n\n---\n\n');
|
||||
inputTextarea?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function extToLang(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', js: 'javascript', py: 'python', rs: 'rust',
|
||||
svelte: 'svelte', html: 'html', css: 'css', json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown',
|
||||
sh: 'bash', sql: 'sql', php: 'php', nix: 'nix', xml: 'xml',
|
||||
vue: 'vue', tsx: 'tsx', jsx: 'jsx', go: 'go', java: 'java',
|
||||
};
|
||||
return map[ext.toLowerCase()] || ext;
|
||||
}
|
||||
|
||||
// Slash-Command Autocomplete State
|
||||
let showCommandPalette = $state(false);
|
||||
let commandQuery = $state('');
|
||||
|
|
@ -923,7 +1019,21 @@
|
|||
}
|
||||
</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">
|
||||
<h2>💬 Chat</h2>
|
||||
<div class="header-stats">
|
||||
|
|
@ -2195,4 +2305,49 @@
|
|||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* File-Drop Overlay */
|
||||
.chat-panel.drag-over {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
font-size: 3rem;
|
||||
animation: dropBounce 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes dropBounce {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
60% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.drop-overlay p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,94 @@
|
|||
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 activeSessionId: string | null = null;
|
||||
let loading = true;
|
||||
let showNewForm = false;
|
||||
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() {
|
||||
try {
|
||||
sessions = await invoke('list_sessions', { limit: 50 });
|
||||
|
|
@ -55,6 +137,7 @@
|
|||
let sessionCreatedListener: UnlistenFn | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
await loadProjects();
|
||||
loadSessions();
|
||||
// Auf Auto-Session-Erstellung vom ChatPanel hören
|
||||
sessionCreatedListener = await listen('session-created', () => {
|
||||
|
|
@ -72,7 +155,7 @@
|
|||
try {
|
||||
const session: Session = await invoke('create_session', {
|
||||
title,
|
||||
workingDir: null,
|
||||
workingDir: activeProject?.working_dir || null,
|
||||
});
|
||||
activeSessionId = session.id;
|
||||
$currentSessionId = session.id;
|
||||
|
|
@ -129,12 +212,88 @@
|
|||
if (usd < 0.01) return `${(usd * 100).toFixed(1)}¢`;
|
||||
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>
|
||||
|
||||
<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">
|
||||
<h2>💬 Sessions</h2>
|
||||
<button class="btn-new" on:click={() => showNewForm = !showNewForm}>
|
||||
<button class="btn-new" onclick={() => showNewForm = !showNewForm}>
|
||||
{showNewForm ? '✕' : '+ Neu'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -145,9 +304,9 @@
|
|||
type="text"
|
||||
bind:value={newTitle}
|
||||
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>
|
||||
{/if}
|
||||
|
||||
|
|
@ -156,7 +315,7 @@
|
|||
{:else if sessions.length === 0}
|
||||
<div class="empty">
|
||||
<p>Keine Sessions vorhanden.</p>
|
||||
<button class="btn-first" on:click={() => { showNewForm = true; }}>
|
||||
<button class="btn-first" onclick={() => { showNewForm = true; }}>
|
||||
Erste Session starten
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -168,7 +327,7 @@
|
|||
<div
|
||||
class="session-item"
|
||||
class:active={session.id === activeSessionId}
|
||||
on:click={() => resumeSession(session.id)}
|
||||
onclick={() => resumeSession(session.id)}
|
||||
>
|
||||
<div class="session-main">
|
||||
<span class="session-status">
|
||||
|
|
@ -194,7 +353,7 @@
|
|||
<span class="session-time">{formatDate(session.updated_at)}</span>
|
||||
<button
|
||||
class="btn-delete"
|
||||
on:click|stopPropagation={() => deleteSession(session.id)}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteSession(session.id); }}
|
||||
title="Session löschen"
|
||||
>
|
||||
🗑️
|
||||
|
|
@ -215,6 +374,186 @@
|
|||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
Loading…
Reference in a new issue