Bugfixes: Resume, Tool-Whitelist, Sub-Agent-Tree, UI-Polish

Bridge (claude-bridge.js):
- Resume-Fix: queryOptions.resume statt .sessionId (SDK-API)
- tools-Whitelist statt disallowedTools (Blacklist vererbt sich auf Sub-Agents!)
  Handlanger: Main nur Task+TodoWrite, Sub-Agents bekommen volles Tool-Set
  Experten: Main nur Task+TodoWrite+Read+Grep+Glob
  Solo: preset claude_code
- handleToolUse/handleToolResult Helper, greifen auch in assistant.content-Bloecken
  (SDK liefert tool_use/tool_result nicht als standalone events)
- Dedup via handledTools Set
- Resume-Retry-Fallback bei ungueltiger Session-ID
- Custom agents-Option entfernt (SDK spawnt Sub-Agents ohne Tools → Halluzination)
- Orchestrator-Prompt: verweist auf general-purpose (vollstaendiges Tool-Set)

Backend (claude.rs):
- claude_session_id NUR beim 1. Mal setzen (sonst verliert man History)
- Generic event emit fuer alle Bridge-Events ans Frontend
- Mode-Persistenz bei Bridge-Start (agent_mode aus DB laden)

Knowledge (knowledge.rs):
- MYSQL_HOST: 192.168.155.1 → 192.168.155.11 (MariaDB-Server)
- MYSQL_PASS: claude → 8715
- category Option<&str> Typ-Annotation fuer exec_map

Programs (programs.rs):
- xvfb_screenshot: Fallback scrot → import (ImageMagick) → ffmpeg

Voice (voice.rs):
- Part::file (existiert nicht) → Part::bytes, keine Temp-Datei

Frontend:
- events.ts: mode-changed Listener, result.text Fallback,
  addAgent({id}) fuer korrekte Parent-Child-Verknuepfung
- ChatPanel: Copy-Button, Typing-Dots in Bubble (kein Doppel-Header),
  $effect statt $:, onkeydown statt on:keydown
- AgentView: "Nur aktive" Toggle, Delegations-Badge, Tool-Count hidden bei 0,
  agentMode Import
- ProgramsPanel: Button-Styling, Error-Banner mit Copy-Button,
  selectable Text
- MonitorPanel: Filter-Dropdown Styling (Hintergrund + Hover)
- SettingsPanel: changeMode() wird beim Klick aufgerufen (nicht nur Store)
- +layout.svelte: agent_mode beim App-Start laden, Mode-Badge im Footer,
  🎓-Button fuer Schulungsfenster
- +page.svelte: Programme-Tab + Hooks-Tab

Neue Dateien:
- TEST-ROADMAP.md — Status und naechste Schritte
- .gitignore erweitert (scheduled_tasks.lock, out/, node_modules)
- vscode-extension/tsconfig.json: include nur src/, exclude node_modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-14 21:24:51 +02:00
parent 120715982b
commit 79b8525ede
14 changed files with 502 additions and 148 deletions

3
.gitignore vendored
View file

@ -27,3 +27,6 @@ Thumbs.db
# Package lock (use npm ci) # Package lock (use npm ci)
package-lock.json package-lock.json
.claude/scheduled_tasks.lock
vscode-extension/out/
vscode-extension/node_modules/

75
TEST-ROADMAP.md Normal file
View file

@ -0,0 +1,75 @@
# Claude Desktop — Test-Roadmap (Fortsetzung)
**Stand:** 14.04.2026 · Session-Ende nahe Token-Limit
## Was bereits getestet & funktioniert
- ✅ Hooks-Panel: 5 Built-in Hooks sichtbar & toggelbar
- ✅ D-Bus: 80+ Services laden (Programme → D-Bus)
- ✅ Schulungs-Fenster öffnet via 🎓-Button
- ✅ Modus-Auswahl in Settings + Footer-Badge
- ✅ Chat funktioniert (nach Bridge-Fixes: `resume` statt `sessionId`, claude_session_id nur bei erstem Call setzen)
- ✅ Sub-Agent erscheint im Tree (nach `addAgent({id})` Fix)
- ✅ Sub-Agent "Nur aktive" Toggle
- ✅ Filter-Dropdown im Monitor-Panel sichtbar
- ✅ Copy-Button in Chat-Nachrichten
- ✅ Error-Banner mit kopierbarem Text im Programme-Panel
## Offene Bugs (Reihenfolge der Priorität)
### 1. Chat-Antwort bei komplexen Flows fehlt (HALB GEFIXT)
**Symptom:** Bei Handlanger-Chats mit Sub-Agent wird die finale Antwort nicht im Chat angezeigt.
**Ursache:** Streaming-Text-Events kommen nicht, nur `result.text` am Ende.
**Fix (drin):** Fallback in `events.ts` auf `result.text` wenn `content` leer.
**Zu verifizieren:** Nächster Chat im Handlanger-Modus — erscheint jetzt die Antwort?
### 2. Date-Panic in Wissensbasis
**Symptom:** `Couldn't convert Row... Date(...) to String` bei jeder Wissens-Suche / Tool-Hints.
**Ursache:** MySQL liefert TIMESTAMP als `Value::Date`, Tupel erwartet `String`.
**Fix:** 7 SELECTs in [knowledge.rs](src-tauri/src/knowledge.rs) einzeln auf `chrono::NaiveDateTime` umstellen.
**Nicht:** `replace_all` auf "created_at, updated_at" — das zerstört Rust-Tupel-Identifier (schon 1× passiert).
### 3. VSCodium-Extension nicht getestet
**Was zu tun:**
- `cd vscode-extension && npm run compile` (bereits OK)
- VSCodium öffnen, Extension via F5 in Dev-Host laden
- App: Programme → 🧩 VSCodium → Port 7890 → Verbinden
- Ping-Test, Datei öffnen
### 4. Xvfb-Screenshot fehlt Tool
**Status:** Xvfb-Start funktioniert, Screenshot braucht `imagemagick` (scrot/ffmpeg-x11 fehlen in NixOS-Build).
**Fix:** `imagemagick` in `/etc/nixos/configuration.nix``nixos-rebuild switch`.
### 5. Experten-Modus nicht getestet
**Analog zu Handlanger:** neue Session, Experten-Modus, Aufgabe mit Research/Implement-Charakter.
### 6. Haiku-Kostenersparnis funktioniert nicht
**Status:** Sub-Agents laufen auf Opus (inherit vom Main). Custom `agents`-Option in SDK scheint ignoriert zu werden bzw. spawnt Agents ohne Tools (halluziniert).
**Nächster Ansatz:** Im Orchestrator-Prompt Claude explizit vorgeben `model: "haiku"` in Task-Calls zu setzen. Ob das SDK das respektiert, ist offen.
## Uncommitted Changes (alles sinnvolle Fixes — lohnt sich zu committen)
- `scripts/claude-bridge.js` — resume-Fix, tools-Whitelist, handleToolUse/Result Helper, Dedup
- `src-tauri/src/claude.rs` — claude_session_id nur 1× setzen, generic event emit
- `src-tauri/src/knowledge.rs` — IP+PW korrekt (155.11/8715)
- `src/lib/stores/events.ts` — mode-changed Listener, result.text Fallback, addAgent({id})
- `src/lib/components/ChatPanel.svelte` — Copy-Button, Typing-Dots in Bubble (kein Doppel-Header)
- `src/lib/components/AgentView.svelte` — Nur-aktive-Toggle, Delegations-Badge, Tool-Count hidden bei 0
- `src/lib/components/ProgramsPanel.svelte` — Error-Banner mit Copy
- `src/lib/components/MonitorPanel.svelte` — Filter-Dropdown Styling
- `src/routes/+layout.svelte` — agent_mode beim Start laden
- `src/routes/+page.svelte` — Tabs Programme + Hooks
## Schnellstart nach Neustart
```bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
CARGO_TARGET_DIR=/tmp/claude-desktop-target nix-shell --run "npx tauri dev"
# Dauert ~15s beim ersten Start nach Reboot wenn /tmp leer ist
```
## DB-Reset wenn Claude-Session-IDs veraltet
```bash
nix-shell -p sqlite --run 'sqlite3 "/home/data/.local/share/de.alles-watt-laeuft.claude-desktop/claude-desktop.db" "UPDATE sessions SET claude_session_id = NULL;"'
```
## Nächster Commit
Alles zusammen ein großer Bugfix-Commit mit Titel:
> Fix: Resume, tools-Whitelist, Sub-Agent-Tree, Date-Handling, UI-Polish

View file

@ -29,26 +29,30 @@ let stickyContext = '';
const ORCHESTRATOR_PROMPTS = { const ORCHESTRATOR_PROMPTS = {
handlanger: ` handlanger: `
Du bist der HAUPT-AGENT und arbeitest im HANDLANGER-MODUS. Du bist der HAUPT-AGENT im HANDLANGER-MODUS.
WICHTIG: Du denkst und planst, aber Sub-Agents führen aus! KRITISCH: Dir stehen NUR Task + TodoWrite zur Verfügung.
Du kannst NICHT direkt lesen, suchen oder ausführen du MUSST delegieren!
Dir steht das Task-Tool zur Verfügung mit dem Sub-Agent-Typ "worker". Task-Tool mit den RICHTIGEN Sub-Agent-Typen:
Der Worker läuft auf Haiku (günstig) und führt genau aus was du sagst. - "general-purpose" Standard-Agent mit VOLLEM Tool-Zugriff (Bash, Read, Write, Grep, Glob).
Nutze diesen für JEDE Aufgabe die Bash/Shell benötigt (ls, cat, find, grep auf System-Ebene).
- "Explore" read-only Agent. NUR für reine Code-/Dateisuche innerhalb des Projekts.
Hat KEINEN Bash-Zugriff! Nicht für Systembefehle wie "ls /etc" verwenden.
Arbeitsweise: Arbeitsweise (verbindlich):
1. ANALYSIERE die Aufgabe 1. Wähle den RICHTIGEN subagent_type basierend auf der Aufgabe.
2. Zerlege in EXAKTE Ausführungsschritte 2. Rufe das Task-Tool auf mit EXAKTER Anweisung.
3. Delegiere jeden Schritt per Task(subagent_type: "worker", prompt: "...") 3. Prüfe im Ergebnis das "tool_uses"-Feld. Wenn tool_uses:0 Sub-Agent hat halluziniert!
4. Formuliere den Task-Prompt als exakte Anweisung, nicht als Frage In dem Fall: Neuer Task-Call mit "general-purpose" statt "Explore".
5. Sammle die Zusammenfassungen, entscheide den nächsten Schritt 4. Verarbeite das Ergebnis und gib dem User die Zusammenfassung.
Beispiel-Delegationen: Halluziniere NIEMALS Dateilisten aus dem Gedächtnis delegiere immer real.
- Task(subagent_type:"worker", prompt:"Lies src/lib.rs, gib mir Zeilen 10-50 zurück")
- Task(subagent_type:"worker", prompt:"Suche 'handleError' in src/ via Grep, liste die Dateien")
- Task(subagent_type:"worker", prompt:"Führe 'npm test' aus, berichte nur passed/failed + Fehlerzeile")
Halte deinen Context klein lass den Worker die Rohdaten bearbeiten! Beispiele:
- "List /etc files" Task(subagent_type:"general-purpose", prompt:"Run 'ls -1 /etc | sort' and return the output")
- "Find handleError in src/" Task(subagent_type:"Explore", prompt:"Grep for 'handleError' in src/")
- "Read /etc/hosts" Task(subagent_type:"general-purpose", prompt:"cat /etc/hosts and return output")
`, `,
experten: ` experten: `
@ -373,49 +377,150 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
abortController: activeAbort, abortController: activeAbort,
}; };
// Session-ID für Fortsetzung hinzufügen wenn vorhanden // Session-ID für Fortsetzung — SDK erwartet `resume`, nicht `sessionId`
if (resumeSessionId) { if (resumeSessionId) {
queryOptions.sessionId = resumeSessionId; queryOptions.resume = resumeSessionId;
} }
// Tool-Filterung + Custom Sub-Agents je nach effektivem Modus // Tool-Konfig je nach Modus.
// Handlanger: Main darf NUR delegieren (Task) und planen (TodoWrite) // WICHTIG: disallowedTools vererbt sich auf Sub-Agents!
// Sub-Agents laufen auf Haiku (siehe HANDLANGER_AGENTS) // Deshalb Whitelist via `tools` nutzen — die gilt nur fuer Main,
// Experten: Main darf zusätzlich lesen/suchen, aber nicht schreiben // Sub-Agents bekommen das volle Standard-Tool-Set.
// Sub-Agents sind autonome Research/Implement/Test/Review queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'];
if (effectiveMode === 'handlanger') { if (effectiveMode === 'handlanger') {
queryOptions.allowedTools = ['Task', 'TodoWrite']; queryOptions.tools = ['Task', 'TodoWrite'];
queryOptions.agents = HANDLANGER_AGENTS; sendMonitorEvent('agent', 'Handlanger: Main nur Task+TodoWrite, Sub-Agents mit vollem Tool-Set', {
sendMonitorEvent('agent', 'Handlanger-Modus: Main → Task+TodoWrite, Worker auf Haiku', {
mode: effectiveMode, mode: effectiveMode,
allowedTools: queryOptions.allowedTools, mainTools: queryOptions.tools,
agents: Object.keys(HANDLANGER_AGENTS),
}); });
} else if (effectiveMode === 'experten') { } else if (effectiveMode === 'experten') {
queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob']; queryOptions.tools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob'];
queryOptions.agents = EXPERTEN_AGENTS; sendMonitorEvent('agent', 'Experten: Main lesen+delegieren, Sub-Agents voll', {
sendMonitorEvent('agent', 'Experten-Modus: 4 autonome Experten verfügbar', {
mode: effectiveMode, mode: effectiveMode,
allowedTools: queryOptions.allowedTools, mainTools: queryOptions.tools,
agents: Object.keys(EXPERTEN_AGENTS),
}); });
} else {
// solo: volles Preset
queryOptions.tools = { type: 'preset', preset: 'claude_code' };
} }
// solo: keine Einschränkung
const conversation = query({ let conversation = query({
prompt: fullPrompt, prompt: fullPrompt,
options: queryOptions, options: queryOptions,
}); });
for await (const event of conversation) { // Dedupe: Manche Tool-Events kommen sowohl in assistant-Blocks
// als auch als standalone tool_use Event. Via toolUseId deduplizieren.
const handledTools = new Set();
// Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events
// als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht
function handleToolUse(ev) {
const toolId = ev.tool_use_id || ev.id || randomUUID();
if (handledTools.has(toolId)) return;
handledTools.add(toolId);
const toolName = ev.name || 'unknown';
const toolInput = ev.input || {};
if (SUBAGENT_TOOLS.includes(toolName)) {
const subagentId = randomUUID();
const subagentType = getSubagentType(toolName, toolInput);
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
const subagentModel = toolInput.model || useModel;
const depth = 1;
activeSubagents.set(toolId, {
agentId: subagentId,
parentId: currentAgentId,
type: subagentType,
task: subagentTask,
depth,
model: subagentModel,
});
sendEvent('subagent-started', {
id: subagentId,
parentAgentId: currentAgentId,
type: subagentType,
task: subagentTask.substring(0, 100),
depth,
model: subagentModel,
toolUseId: toolId,
});
}
sendEvent('tool-start', {
id: toolId,
tool: toolName,
input: toolInput,
agentId: currentAgentId,
});
const toolSummary = summarizeToolInput(toolName, toolInput);
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
toolId,
tool: toolName,
input: toolInput,
});
}
// Tool-Result handhaben
function handleToolResult(ev) {
const toolId = ev.tool_use_id || '';
if (activeSubagents.has(toolId)) {
const subagent = activeSubagents.get(toolId);
sendEvent('subagent-stopped', {
id: subagent.agentId,
parentAgentId: subagent.parentId,
success: !ev.is_error,
toolUseId: toolId,
});
activeSubagents.delete(toolId);
}
sendEvent('tool-end', {
id: toolId,
success: !ev.is_error,
agentId: currentAgentId,
});
}
// Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID
async function* iterateWithRetry() {
try {
for await (const ev of conversation) yield ev;
} catch (err) {
// Wenn Resume-Session ungueltig → Retry ohne sessionId
if (queryOptions.sessionId) {
sendMonitorEvent('agent', 'Resume fehlgeschlagen, starte neue Session', {
reason: err.message || String(err),
oldSessionId: queryOptions.sessionId,
});
delete queryOptions.sessionId;
conversation = query({ prompt: fullPrompt, options: queryOptions });
for await (const ev of conversation) yield ev;
} else {
throw err;
}
}
}
for await (const event of iterateWithRetry()) {
switch (event.type) { switch (event.type) {
case 'assistant': case 'assistant':
// Text aus der Nachricht extrahieren // Content-Bloecke durchgehen (Text, tool_use, thinking, ...)
if (event.message?.content) { if (event.message?.content) {
for (const block of event.message.content) { for (const block of event.message.content) {
if (block.type === 'text' && block.text) { if (block.type === 'text' && block.text) {
fullText += block.text; fullText += block.text;
sendEvent('text', { text: block.text }); sendEvent('text', { text: block.text });
} else if (block.type === 'tool_use') {
// Tool-Call von Main-Agent — manuell weiterreichen, damit
// der tool_use-Case weiter unten greift
handleToolUse(block);
} }
} }
} }
@ -425,78 +530,24 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
break; break;
case 'tool_use': { case 'tool_use': {
const toolId = event.tool_use_id || randomUUID(); handleToolUse(event);
const toolName = event.name || 'unknown';
const toolInput = event.input || {};
// Prüfen ob dieses Tool einen Subagent startet
if (SUBAGENT_TOOLS.includes(toolName)) {
const subagentId = randomUUID();
const subagentType = getSubagentType(toolName, toolInput);
const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || 'Subagent-Aufgabe';
const subagentModel = toolInput.model || useModel;
// Tiefe berechnen (Main = 0, erster Sub = 1, etc.)
// Für jetzt: immer depth 1 (direkter Subagent vom Main)
const depth = 1;
activeSubagents.set(toolId, {
agentId: subagentId,
parentId: currentAgentId,
type: subagentType,
task: subagentTask,
depth,
model: subagentModel,
});
sendEvent('subagent-started', {
id: subagentId,
parentAgentId: currentAgentId,
type: subagentType,
task: subagentTask.substring(0, 100),
depth,
model: subagentModel,
toolUseId: toolId,
});
}
sendEvent('tool-start', {
id: toolId,
tool: toolName,
input: toolInput,
agentId: currentAgentId,
});
// Monitor: Tool gestartet
const toolSummary = summarizeToolInput(toolName, toolInput);
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
toolId,
tool: toolName,
input: toolInput,
});
break; break;
} }
case 'tool_result': { case 'tool_result': {
const toolId = event.tool_use_id || ''; handleToolResult(event);
break;
}
// Prüfen ob dieser Tool-Call ein Subagent war case 'user': {
if (activeSubagents.has(toolId)) { // tool_result kommt vom SDK meist als Block innerhalb user-message
const subagent = activeSubagents.get(toolId); if (event.message?.content) {
sendEvent('subagent-stopped', { for (const block of event.message.content) {
id: subagent.agentId, if (block.type === 'tool_result') {
parentAgentId: subagent.parentId, handleToolResult(block);
success: !event.is_error, }
toolUseId: toolId, }
});
activeSubagents.delete(toolId);
} }
sendEvent('tool-end', {
id: toolId,
success: !event.is_error,
agentId: currentAgentId,
});
break; break;
} }

View file

@ -228,7 +228,11 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") { if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") {
if !active_id.is_empty() { if !active_id.is_empty() {
if let Ok(Some(mut session)) = db_lock.get_session(&active_id) { if let Ok(Some(mut session)) = db_lock.get_session(&active_id) {
session.claude_session_id = Some(sid.to_string()); // claude_session_id nur beim ersten Mal setzen —
// sonst verlieren Folge-Chats den Kontext der Anfangs-History
if session.claude_session_id.is_none() {
session.claude_session_id = Some(sid.to_string());
}
session.message_count += 1; session.message_count += 1;
if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) { if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) {
session.cost_usd += cost; session.cost_usd += cost;

View file

@ -5,10 +5,10 @@ use mysql_async::{Pool, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Verbindungskonfiguration /// Verbindungskonfiguration
const MYSQL_HOST: &str = "192.168.155.1"; const MYSQL_HOST: &str = "192.168.155.11";
const MYSQL_PORT: u16 = 3306; const MYSQL_PORT: u16 = 3306;
const MYSQL_USER: &str = "claude"; const MYSQL_USER: &str = "claude";
const MYSQL_PASS: &str = "claude"; const MYSQL_PASS: &str = "8715";
const MYSQL_DB: &str = "claude"; const MYSQL_DB: &str = "claude";
/// Wissenseintrag aus der knowledge-Tabelle /// Wissenseintrag aus der knowledge-Tabelle

View file

@ -155,27 +155,57 @@ pub async fn xvfb_status() -> Result<XvfbStatus, String> {
#[tauri::command] #[tauri::command]
pub async fn xvfb_screenshot(display_num: Option<u16>) -> Result<String, String> { pub async fn xvfb_screenshot(display_num: Option<u16>) -> Result<String, String> {
// Nimmt Screenshot vom virtuellen Display via scrot oder import // Screenshot vom virtuellen Display — probiert mehrere Tools durch
let display = display_num.unwrap_or(1); let display = display_num.unwrap_or(1);
let display_env = format!(":{}", display);
let tmp = std::env::temp_dir().join(format!("claude-xvfb-{}.png", uuid::Uuid::new_v4())); let tmp = std::env::temp_dir().join(format!("claude-xvfb-{}.png", uuid::Uuid::new_v4()));
let result = Command::new("scrot") // Versuchte Kommandos in Reihenfolge
.env("DISPLAY", format!(":{}", display)) let attempts: Vec<(&str, Vec<String>)> = vec![
.arg("-q") ("scrot", vec!["-q".into(), "80".into(), tmp.to_string_lossy().into_owned()]),
.arg("80") ("import", vec!["-window".into(), "root".into(), tmp.to_string_lossy().into_owned()]),
.arg(&tmp) ("ffmpeg", vec![
.output(); "-f".into(), "x11grab".into(),
"-i".into(), display_env.clone(),
"-frames:v".into(), "1".into(),
"-y".into(),
tmp.to_string_lossy().into_owned(),
]),
];
match result { let mut last_err = String::new();
Ok(o) if o.status.success() => { for (cmd, args) in &attempts {
let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?; // Tool vorhanden?
let _ = std::fs::remove_file(&tmp); if Command::new("which").arg(cmd).output().map(|o| !o.status.success()).unwrap_or(true) {
use base64::Engine; last_err = format!("'{}' nicht installiert", cmd);
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) continue;
}
let result = Command::new(cmd)
.env("DISPLAY", &display_env)
.args(args)
.output();
match result {
Ok(o) if o.status.success() && tmp.exists() => {
let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(&tmp);
use base64::Engine;
return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
Ok(o) => {
last_err = format!("{} Exit {}: {}", cmd, o.status, String::from_utf8_lossy(&o.stderr));
}
Err(e) => {
last_err = format!("{} Fehler: {}", cmd, e);
}
} }
_ => Err("scrot fehlgeschlagen. Alternativ: import von ImageMagick installieren.".into()),
} }
Err(format!(
"Screenshot fehlgeschlagen. Installiere eines: scrot / imagemagick / ffmpeg.\nLetzter Fehler: {}",
last_err
))
} }
// ============ Playwright-Infos ============ // ============ Playwright-Infos ============

View file

@ -1,6 +1,22 @@
<script lang="ts"> <script lang="ts">
import { agents, selectedAgentId, agentCount, agentTree, agentMode, type AgentTreeNode } from '$lib/stores/app'; import { agents, selectedAgentId, agentCount, agentTree, agentMode, type AgentTreeNode } from '$lib/stores/app';
import type { Agent } from '$lib/stores/app'; import type { Agent } from '$lib/stores/app';
import { derived } from 'svelte/store';
let onlyActive = $state(false);
// Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active'
const filteredTree = derived([agentTree], ([$tree]) => {
if (!onlyActive) return $tree;
function prune(node: AgentTreeNode): AgentTreeNode | null {
const kids = node.children.map(prune).filter((n): n is AgentTreeNode => n !== null);
if (node.agent.status === 'active' || kids.length > 0) {
return { agent: node.agent, children: kids };
}
return null;
}
return $tree.map(prune).filter((n): n is AgentTreeNode => n !== null);
});
// Delegations-Badge-Text je nach Agent-Modus // Delegations-Badge-Text je nach Agent-Modus
const delegationBadges: Record<string, { label: string; cssClass: string }> = { const delegationBadges: Record<string, { label: string; cssClass: string }> = {
@ -115,7 +131,9 @@
</div> </div>
<div class="agent-task">{agent.task}</div> <div class="agent-task">{agent.task}</div>
<div class="agent-meta"> <div class="agent-meta">
<span class="agent-tools">🔧 {agent.toolCalls.length}</span> {#if agent.toolCalls.length > 0}
<span class="agent-tools">🔧 {agent.toolCalls.length}</span>
{/if}
{#if depth > 0} {#if depth > 0}
<span class="agent-depth">Ebene {depth}</span> <span class="agent-depth">Ebene {depth}</span>
{#if $agentMode && $agentMode !== 'solo' && delegationBadges[$agentMode]} {#if $agentMode && $agentMode !== 'solo' && delegationBadges[$agentMode]}
@ -149,6 +167,10 @@
{$agentCount.subAgents} Sub | {$agentCount.subAgents} Sub |
{$agentCount.active} aktiv {$agentCount.active} aktiv
</div> </div>
<label class="filter-toggle">
<input type="checkbox" bind:checked={onlyActive} />
Nur aktive
</label>
</div> </div>
{#if $agents.length === 0} {#if $agents.length === 0}
@ -158,7 +180,7 @@
</div> </div>
{:else} {:else}
<div class="agent-tree"> <div class="agent-tree">
{#each $agentTree as rootNode} {#each $filteredTree as rootNode}
{@render agentNode(rootNode, 0)} {@render agentNode(rootNode, 0)}
{/each} {/each}
</div> </div>
@ -248,6 +270,20 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.filter-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.filter-toggle:hover {
color: var(--text-primary);
}
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -121,13 +121,17 @@
} }
} }
$: if ($messages.length) scrollToBottom(); $effect(() => {
if ($messages.length) scrollToBottom();
});
// Bei Session-Wechsel: Compacting-Flag zurücksetzen // Bei Session-Wechsel: Compacting-Flag zurücksetzen
$: if ($currentSessionId) { $effect(() => {
compactingWarningShown = false; if ($currentSessionId) {
showCompactingDialog = false; compactingWarningShown = false;
} showCompactingDialog = false;
}
});
// Token-Schätzung: ~4 Zeichen pro Token // Token-Schätzung: ~4 Zeichen pro Token
function estimateTokensForMessages(msgs: Message[]): number { function estimateTokensForMessages(msgs: Message[]): number {
@ -551,6 +555,20 @@
{ id: 'ids', label: 'IDs/Referenzen' }, { id: 'ids', label: 'IDs/Referenzen' },
]; ];
let copyFeedback = $state<string | null>(null);
async function copyMessage(message: Message) {
try {
await navigator.clipboard.writeText(message.content);
copyFeedback = message.id;
setTimeout(() => {
if (copyFeedback === message.id) copyFeedback = null;
}, 1500);
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err);
}
}
function openRememberDialog(message: Message) { function openRememberDialog(message: Message) {
rememberContent = message.content; rememberContent = message.content;
rememberEntry = { rememberEntry = {
@ -638,6 +656,9 @@
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button> <button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
{/if} {/if}
{#if message.content && message.role !== 'system'} {#if message.content && message.role !== 'system'}
<button class="action-btn" onclick={() => copyMessage(message)} title="Nachricht kopieren">
{copyFeedback === message.id ? '✓' : '📋'}
</button>
<button class="action-btn" onclick={() => openRememberDialog(message)} title="Das merken">💡</button> <button class="action-btn" onclick={() => openRememberDialog(message)} title="Das merken">💡</button>
{/if} {/if}
<span class="message-time"> <span class="message-time">
@ -660,7 +681,15 @@
</button> </button>
</div> </div>
{:else if message.role === 'assistant'} {:else if message.role === 'assistant'}
{@html renderMarkdown(message.content)} {#if message.content}
{@html renderMarkdown(message.content)}
{:else if $isProcessing}
<span class="typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
{/if}
{:else} {:else}
{message.content} {message.content}
{/if} {/if}
@ -671,7 +700,8 @@
{#if $isProcessing} {#if $isProcessing}
{@const lastMsg = $messages.at(-1)} {@const lastMsg = $messages.at(-1)}
{#if !lastMsg || lastMsg.role !== 'assistant' || lastMsg.content === ''} {#if !lastMsg || lastMsg.role !== 'assistant'}
<!-- Nur zeigen wenn noch gar keine assistant-message da ist -->
<div class="message assistant typing-msg"> <div class="message assistant typing-msg">
<div class="message-header"> <div class="message-header">
<span class="message-role">🤖 Claude</span> <span class="message-role">🤖 Claude</span>
@ -696,7 +726,7 @@
<textarea <textarea
bind:this={inputTextarea} bind:this={inputTextarea}
bind:value={$currentInput} bind:value={$currentInput}
on:keydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)" placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
disabled={$isProcessing || isRecording} disabled={$isProcessing || isRecording}
rows="3" rows="3"
@ -705,7 +735,7 @@
<button <button
class="mic-button" class="mic-button"
class:recording={isRecording} class:recording={isRecording}
on:click={toggleRecording} onclick={toggleRecording}
disabled={$isProcessing} disabled={$isProcessing}
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'} title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
> >
@ -718,7 +748,7 @@
</button> </button>
<button <button
class="send-button" class="send-button"
on:click={sendMessage} onclick={sendMessage}
disabled={!$currentInput.trim() || $isProcessing || isRecording} disabled={!$currentInput.trim() || $isProcessing || isRecording}
> >
{#if $isProcessing} {#if $isProcessing}

View file

@ -288,10 +288,22 @@
.filter-select { .filter-select {
padding: 4px 8px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
background: var(--bg-tertiary); background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
}
.filter-select:hover,
.filter-select:focus {
border-color: var(--accent);
outline: none;
}
.filter-select option {
background: var(--bg-secondary);
color: var(--text-primary);
} }
.auto-scroll-toggle { .auto-scroll-toggle {

View file

@ -22,6 +22,17 @@
let dbusServices = $state<string[]>([]); let dbusServices = $state<string[]>([]);
let dbusLoading = $state(false); let dbusLoading = $state(false);
let screenshot = $state<string | null>(null); let screenshot = $state<string | null>(null);
let errorMsg = $state<string | null>(null);
async function copyError() {
if (errorMsg) {
try {
await navigator.clipboard.writeText(errorMsg);
} catch (e) {
console.error('Clipboard:', e);
}
}
}
async function loadXvfb() { async function loadXvfb() {
try { try {
@ -38,7 +49,7 @@
resolution: xvfb.resolution resolution: xvfb.resolution
}); });
} catch (err) { } catch (err) {
alert(err); errorMsg = String(err);
} }
} }
@ -47,7 +58,7 @@
xvfb = await invoke<XvfbStatus>('xvfb_stop'); xvfb = await invoke<XvfbStatus>('xvfb_stop');
screenshot = null; screenshot = null;
} catch (err) { } catch (err) {
alert(err); errorMsg = String(err);
} }
} }
@ -56,7 +67,7 @@
const b64 = await invoke<string>('xvfb_screenshot', { displayNum: xvfb.display_num }); const b64 = await invoke<string>('xvfb_screenshot', { displayNum: xvfb.display_num });
screenshot = `data:image/png;base64,${b64}`; screenshot = `data:image/png;base64,${b64}`;
} catch (err) { } catch (err) {
alert(err); errorMsg = String(err);
} }
} }
@ -72,7 +83,7 @@
dbusServices = await invoke<string[]>('dbus_list_services', { session: true }); dbusServices = await invoke<string[]>('dbus_list_services', { session: true });
} catch (err) { } catch (err) {
dbusServices = []; dbusServices = [];
alert(err); errorMsg = String(err);
} finally { } finally {
dbusLoading = false; dbusLoading = false;
} }
@ -92,6 +103,19 @@
<button class:active={section === 'xvfb'} onclick={() => (section = 'xvfb')}>🖥️ Xvfb</button> <button class:active={section === 'xvfb'} onclick={() => (section = 'xvfb')}>🖥️ Xvfb</button>
</div> </div>
{#if errorMsg}
<div class="error-banner">
<div class="error-header">
<span>⚠️ Fehler</span>
<div class="error-actions">
<button onclick={copyError} title="In Zwischenablage kopieren">📋 Kopieren</button>
<button onclick={() => (errorMsg = null)} title="Schließen"></button>
</div>
</div>
<pre class="error-text">{errorMsg}</pre>
</div>
{/if}
<div class="section-body"> <div class="section-body">
{#if section === 'ide'} {#if section === 'ide'}
<IdePanel /> <IdePanel />
@ -224,15 +248,36 @@
} }
.controls button { .controls button {
padding: 4px 10px; padding: 6px 14px;
cursor: pointer; cursor: pointer;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
transition: background 0.15s, border-color 0.15s;
}
.controls button:hover:not(:disabled) {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.controls button.primary { .controls button.primary {
background: var(--accent); background: var(--accent);
color: white; color: white;
border: none; border: 1px solid var(--accent);
border-radius: var(--radius-sm); }
.controls button.primary:hover:not(:disabled) {
filter: brightness(1.15);
} }
.dbus-list { .dbus-list {
@ -275,4 +320,55 @@
font-size: 0.8rem; font-size: 0.8rem;
font-style: italic; font-style: italic;
} }
.error-banner {
margin: var(--spacing-sm);
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.4);
border-radius: var(--radius-sm);
overflow: hidden;
}
.error-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px var(--spacing-sm);
background: rgba(248, 113, 113, 0.15);
color: #f87171;
font-weight: 600;
font-size: 0.82rem;
}
.error-actions {
display: flex;
gap: var(--spacing-xs);
}
.error-actions button {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.4);
color: #f87171;
padding: 2px 8px;
font-size: 0.75rem;
cursor: pointer;
border-radius: 3px;
}
.error-actions button:hover {
background: rgba(248, 113, 113, 0.2);
}
.error-text {
margin: 0;
padding: var(--spacing-sm);
font-family: monospace;
font-size: 0.78rem;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
user-select: text;
}
</style> </style>

View file

@ -166,7 +166,7 @@
<button <button
class="mode-card" class="mode-card"
class:selected={$agentMode === mode.id} class:selected={$agentMode === mode.id}
on:click={() => $agentMode = mode.id} on:click={() => changeMode(mode.id)}
> >
<div class="mode-header"> <div class="mode-header">
<span class="mode-icon">{mode.icon}</span> <span class="mode-icon">{mode.icon}</span>

View file

@ -71,6 +71,7 @@ interface ResultEvent {
}; };
session_id?: string; session_id?: string;
model?: string; model?: string;
text?: string;
} }
interface MonitorEventPayload { interface MonitorEventPayload {
@ -120,10 +121,11 @@ export async function initEventListeners(): Promise<void> {
// Agent gestartet // Agent gestartet
listeners.push( listeners.push(
await listen<AgentEvent>('agent-started', (event) => { await listen<AgentEvent>('agent-started', (event) => {
const { id, type, task } = event.payload; const { id, type, task, model } = event.payload;
console.log('🤖 Agent gestartet:', id, type); console.log('🤖 Agent gestartet:', id, type);
addAgent(mapAgentType(type || 'main'), task || 'Verarbeite...'); // WICHTIG: id mitgeben, sonst stimmt parentAgentId der Sub-Agents nicht!
addAgent(mapAgentType(type || 'main'), task || 'Verarbeite...', { id, model });
isProcessing.set(true); isProcessing.set(true);
// Leere Streaming-Nachricht anlegen // Leere Streaming-Nachricht anlegen
@ -264,7 +266,7 @@ export async function initEventListeners(): Promise<void> {
// Ergebnis (Kosten, Token, Modell) // Ergebnis (Kosten, Token, Modell)
listeners.push( listeners.push(
await listen<ResultEvent>('claude-result', async (event) => { await listen<ResultEvent>('claude-result', async (event) => {
const { cost, tokens, session_id, model } = event.payload; const { cost, tokens, session_id, model, text } = event.payload;
console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model, session_id }); console.log('📊 Ergebnis:', { cost: cost ? `$${cost.toFixed(4)}` : '-', tokens, model, session_id });
// Modell an die Streaming-Nachricht anhängen und speichern // Modell an die Streaming-Nachricht anhängen und speichern
@ -274,7 +276,9 @@ export async function initEventListeners(): Promise<void> {
messages.update((msgs) => { messages.update((msgs) => {
return msgs.map((m) => { return msgs.map((m) => {
if (m.id === streamingMessageId) { if (m.id === streamingMessageId) {
finalMessage = { ...m, model: model || m.model }; // Fallback: wenn kein Streaming-Text kam, result.text nutzen
const content = m.content && m.content.trim() ? m.content : (text || '');
finalMessage = { ...m, content, model: model || m.model };
return finalMessage; return finalMessage;
} }
return m; return m;

View file

@ -45,6 +45,16 @@
console.warn('Modell konnte nicht geladen werden:', err); console.warn('Modell konnte nicht geladen werden:', err);
} }
// Agent-Modus aus Settings laden (sonst Badge nicht sofort sichtbar)
try {
const mode: string = await invoke('get_agent_mode');
if (mode) {
$agentMode = mode as AgentMode;
}
} catch (err) {
console.warn('Agent-Modus konnte nicht geladen werden:', err);
}
// Sticky Context beim Start laden und an Bridge senden // Sticky Context beim Start laden und an Bridge senden
try { try {
const ctx: StickyContextResponse = await invoke('init_sticky_context'); const ctx: StickyContextResponse = await invoke('init_sticky_context');

View file

@ -7,7 +7,10 @@
"rootDir": "src", "rootDir": "src",
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"esModuleInterop": true "esModuleInterop": true,
"types": ["node", "vscode", "ws"],
"typeRoots": ["./node_modules/@types"]
}, },
"exclude": ["node_modules", ".vscode-test"] "include": ["src/**/*"],
"exclude": ["node_modules", ".vscode-test", "../node_modules"]
} }