feat: Phase 8+9 — Inline Tool-Karten + komplettes UI-Redesign auf Cursor/Zed-Niveau [appimage]
Some checks failed
Build AppImage / build (push) Has been cancelled

Phase 8 (VS-Code-Look Chatbereich):
- Linksbuendige Messages mit Avatar-Spalte, Hover-Actions
- Inline Tool-Karten (Read/Edit/Bash/Generic) in Assistant-Messages
- Edit-Karten zeigen Diff direkt mit Accept/Reject
- Tool-Calls werden via events.ts an letzte Assistant-Message gebunden
- Smart-Sticky-Scroll (Auto-Scroll stoppt wenn User selbst scrollt)
- OOM-Bug durch MutationObserver mit subtree:true behoben

Phase 9 (Komplettes UI-Redesign):
- Design-System in app.css: 4 Graustufen, 1 Akzent (#007acc), 4 Status-Farben,
  5 Schriftgroessen (11/12/13/14/16), 4-Punkt-Spacing, 2 Radius-Werte
- vscode.css als Aliase auf das neue System
- UI-Library src/lib/ui/: Button, Card, Icon, Badge, StatusDot, Tooltip, Drawer, Tabs
- Lucide-svelte fuer SVG-Icons (ersetzt Emojis im Chrome)
- StatusBar (22px) ersetzt ueberfuellten Footer mit 6+ Stats
- Titlebar entruempelt: ✱-Logo + Stop + Schulungsmodus + Version
- 2-spaltiges Layout (Sidebar 240px + Hauptbereich) statt 4-Pane-Zerstueckelung
- ToolDrawer: 13 Panels in 4 Gruppen (Aktivitaet/Speicher/Werkzeuge/Einstellungen),
  jede Gruppe mit internen Tabs, Esc schliesst
- Cmd+K global oeffnet QuickActions als zentrale Navigation
- StatusDot-Komponente ersetzt Emoji-Status (🟢🟡🔴) in AgentView
- Hardgecodete Farben (#ef4444, #22c55e, #eab308 ...) in 9 Komponenten durch
  CSS-Variablen ersetzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-27 14:27:09 +02:00
parent 4780128c6f
commit ad9833fcb8
43 changed files with 3406 additions and 764 deletions

View file

@ -6,6 +6,45 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
---
## [Unreleased] - 2026-04-27
### Hinzugefügt (Phase 9: UI-Redesign Schritt 2 — 2-spaltiges Layout + Drawer + Komponenten-Pass)
- **Sidebar.svelte** (NEU): 240px-Sidebar mit Cmd+K-Suche oben, Sessions-Liste in der Mitte, Nav-Rail unten mit 4 Lucide-Icons (Aktivität/Speicher/Werkzeuge/Einstellungen) — ersetzt die alte separate SessionList-Pane
- **ToolDrawer.svelte** (NEU): Rechts-eingeschobener 420px-Drawer mit internen Tabs pro Sektion — Activity (Live/Monitor/Kosten), Speicher (Gedächtnis/Wissensbasis/Kontext), Werkzeuge (Programme/Sprache/Agenten/Guard-Rails/Hooks), Einstellungen (Settings/Audit). Esc schließt
- **2-spaltiges Layout** in [+page.svelte](src/routes/+page.svelte): das alte 4-Pane-PaneForge-Layout (Sessions/Chat/Mid-Tabs/Right-Tabs) ist aufgelöst. Jetzt: Sidebar (fix 240px) + ChatPanel (flex) + Drawer als Overlay. Kein Wirrwarr aus 13 nebeneinander liegenden Tabs mehr
- **Cmd+K global**: globaler Listener im +page.svelte öffnet QuickActions; bestehender `navigate-tab`-Event mappt automatisch auf die richtige Drawer-Sektion
- **StatusDot in AgentView**: `🟢 🟡 ⚪ 🔴`-Emojis durch `<StatusDot>`-Komponente ersetzt — saubere CSS-Dots mit Pulse-Animation bei aktiven Agenten
- **Hardgecodete Farben raus** in 9 Komponenten: `#ef4444`, `#22c55e`, `#eab308`, `#f59e0b`, `#a855f7`, `#06b6d4`, `#a78bfa`, `#8b5cf6`, `#60a5fa` durch `var(--status-success/warning/error/info)` und `var(--accent)` ersetzt — betroffen: GuardRailsPanel, AgentView, ChatPanel, PerformancePanel, ProgramsPanel, SettingsPanel, VoicePanel, IdePanel, AutoCorrectionModal, UpdateDialog
- **ChatPanel entkoppelt**: lokaler Header (mit Spark-Icon, Stats, Detach) und ChatStatusBar entfernt — die Funktionen leben in der globalen Titlebar bzw. Statusbar. Im ChatPanel bleibt nur eine kompakte 28px-Toolbar mit Detach-Button
### Hinzugefügt (Phase 9: UI-Redesign Schritt 1 — Design-System + Status-Bar)
- **Design-System** in [src/app.css](src/app.css): 4 Graustufen (`--bg-primary/secondary/tertiary/input`), 1 Akzent `#007acc` (VS-Code-Blau), 4 Status-Farben (`--status-success/warning/error/info`), 5 Schriftgrößen (`--fs-xs/sm/md/lg/xl`), 4-Punkt-Spacing (`--sp-1..6`), 2 Border-Radius-Werte (`--r-sm/md`); vorherige KDE-Breeze-Werte abgelöst
- **vscode.css als Aliase**: [src/lib/theme/vscode.css](src/lib/theme/vscode.css) mappt `--vscode-*` Variablen auf das neue System — Phase-8-Komponenten laufen unverändert weiter
- **UI-Library** [src/lib/ui/](src/lib/ui/): Button, Card, Icon (Lucide-Wrapper), Badge, StatusDot (CSS statt Emoji), Tooltip, Drawer (Esc-schließbar), Tabs — verbindliche Bausteine für alle Panels
- **Lucide-Icons**: `lucide-svelte` installiert, ersetzt Emojis im UI-Chrome (Phase 9 Schritt-für-Schritt)
- **StatusBar.svelte** (NEU): kompakte 22px-Footer-Zeile mit Token-Auslastung (Färbung ab 70/90%), Modell+Modus (klickbarer Picker), Session-Kosten, Verarbeitungs-Phase — ersetzt den überfüllten alten Footer mit 6+ Stats und Pulse-Animation
- **Titlebar entrümpelt**: nur noch Logo (✱), Stop-Button, Schulungsmodus (Lucide-Icon statt 🎓), Version — Status-Dot entfernt, doppelte Modell-Anzeige entfernt
### Geändert
- **Footer ersetzt**: alter `<footer class="footer">` aus [+layout.svelte](src/routes/+layout.svelte) komplett entfernt, ersetzt durch globale `<StatusBar />`
- **Hardgecodete Farben** in `+layout.svelte` (`#22c55e`, `#ef4444`, `#eab308`, `#f59e0b`, `#a855f7`, `#06b6d4`) entfernt — durch CSS-Variablen ersetzt
### Hinzugefügt (Phase 8: VS-Code-Look — Chatbereich-Redesign)
- **VS-Code-Dark-Theme**: Globales Theme-File `src/lib/theme/vscode.css` mit `--vscode-*` Custom-Properties (Editor-BG #1e1e1e, Sidebar #252526, Akzent #0e639c, Diff-Grün/-Rot wie im Original); bestehende `--bg-primary`, `--accent` usw. werden als Aliase auf VS-Code-Werte gemappt → alle Panels ziehen automatisch das neue Theme
- **Linksbündiges Message-Layout**: Neue [Message.svelte](src/lib/components/Message.svelte) und [MessageList.svelte](src/lib/components/MessageList.svelte) — User und Assistant beide linksbündig mit Avatar-Kreis (24px) vorn, durchgehende Zeitachse wie in der Claude-Code-Extension; Hover-Actions (Edit/Regenerate/Copy/Merken/Rewind) erscheinen rechts oben
- **Inline Tool-Karten**: Tool-Calls erscheinen jetzt als ausklappbare Karten direkt in der Assistant-Message statt nur im Activity-Panel — neue [ToolCallCard.svelte](src/lib/components/ToolCallCard.svelte) Basis + Spezialisierungen [ToolCardRead](src/lib/components/ToolCardRead.svelte) (Code-Snippet mit Zeilennummern), [ToolCardEdit](src/lib/components/ToolCardEdit.svelte) (Diff + Accept/Reject inline), [ToolCardBash](src/lib/components/ToolCardBash.svelte) (Terminal-Output), [ToolCardGeneric](src/lib/components/ToolCardGeneric.svelte) (Grep/Glob/WebFetch/MCP/Task)
- **Tool-Call-Binding an Message**: `Message.toolCalls?: InlineToolCall[]` ergänzt; events.ts hängt `tool-start` an die letzte Assistant-Message und finalisiert sie bei `tool-end` — Karten werden live gerendert
- **Smart-Sticky-Scroll**: MessageList scrollt automatisch ans Ende, stoppt aber wenn der User selbst gescrollt hat; Back-to-Bottom-Button erscheint dann
- **ChatStatusBar**: Neue Statusbar unter dem Input mit Token-Auslastung (mit Warning/Danger-Färbung), Modus-Picker (Solo/Handlanger/Experten/Auto, Klick öffnet Menü), Modell-Badge, Phase-Indikator, Shortcut-Hints
- **Tool-Card-Helper**: [src/lib/utils/toolCards.ts](src/lib/utils/toolCards.ts) mit `getToolMeta()` und `getToolSubtitle()` für Icon-/Label-/Subtitel-Zuordnung; [markdown.ts](src/lib/utils/markdown.ts) extrahiert den Markdown-Renderer aus ChatPanel
- **Header-Redesign**: Spark-Icon ✱ statt 💬, kompakte Stats, VS-Code-Sidebar-Hintergrund
### Geändert
- ChatPanel.svelte (~50KB CSS) rendert jetzt Messages über `<MessageList />` statt inline; der separate Pending-Changes-Block unten wurde entfernt — Diffs erscheinen direkt in den Edit-Tool-Karten
- Backend (claude.rs, claude-bridge.js, checkpoint.rs) bleibt unverändert — reines UI-Refactor
---
## [Unreleased] - 2026-04-22
### Hinzugefügt (Phase 7: VS Code Extension Features)

View file

@ -1,6 +1,6 @@
# Claude Desktop — Roadmap
Stand: 22.04.2026
Stand: 27.04.2026
---
@ -99,7 +99,7 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
---
## Phase 7: VS Code Extension Features (aktuell)
## Phase 7: VS Code Extension Features
**Ziel:** Die besten Features der Claude Code VS Code Extension uebernehmen — Accept/Reject, @-Mentions, Checkpoints.
@ -114,6 +114,47 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
---
## Phase 8: VS-Code-Look — Chatbereich-Redesign
**Ziel:** Chatbereich strukturell und visuell wie die offizielle Claude Code Extension fuer VS Code/Codium gestalten — linksbuendige Messages, Inline-Tool-Karten, VS-Code-Dark-Theme. ChatPanel.svelte (~2000 Z.) wird in fokussierte Teilkomponenten zerlegt.
| Feature | Datei(en) | Status |
|---------|-----------|--------|
| ✅ VS-Code-Theme-Variablen | `theme/vscode.css`, `+layout.svelte` | `--vscode-*` Custom Properties + Aliase fuer Altcode |
| ✅ Inline-Render via MessageList | `ChatPanel.svelte` | Messages-Bereich in `<MessageList />` ausgelagert; Logik bleibt im Container |
| ✅ Message.svelte | `Message.svelte` (NEU) | Avatar links, linksbuendig, Hover-Actions (Edit/Regenerate/Copy/Merken/Rewind) |
| ✅ MessageList.svelte | `MessageList.svelte` (NEU) | Smart-Sticky-Scroll, Back-to-Bottom-Button |
| ✅ Inline Tool-Cards | `ToolCallCard.svelte`, `ToolCardRead/Edit/Bash/Generic.svelte` (NEU) | Klappbare Karten in Assistant-Message; Edit zeigt Diff + Accept/Reject |
| ✅ Tool-Calls an Message binden | `events.ts`, `app.ts` | `Message.toolCalls?[]` ergaenzt, Tool-Events an letzte Assistant-Msg gebunden |
| ✅ ChatStatusBar | `ChatStatusBar.svelte` (NEU) | Token/Mode/Modell + Modus-Picker; Header bekommt Spark-Icon |
| ✅ Eigen-Features-Mapping | — | Mic, Modus-Indikator, Detach, „Das merken" funktionieren weiter |
| ✅ Activity-Panel parallel | `ActivityPanel.svelte` | unveraendert, dient als History-View neben den Inline-Karten |
---
## Phase 9: Komplettes UI-Redesign — Cursor/Zed-Niveau (aktuell)
**Ziel:** Schluss mit Emoji-Inflation, 4-Pane-Zerstueckelung und Schriftgroessen-Chaos. Verbindliches Design-System (Farben/Typo/Spacing/Radius), 2-spaltiges Layout (Sidebar + Hauptbereich mit Tabs), Werkzeug-Panels als Drawer von rechts statt zwei nebeneinander liegender Tab-Reihen.
| Feature | Datei(en) | Status |
|---------|-----------|--------|
| ✅ Design-System (Variablen) | `app.css` | 4 Graustufen, 1 Akzent (#007acc), 4 Status-Farben, 5 Schriftgroessen, 4-Punkt-Spacing, 2 Radius-Werte |
| ✅ vscode.css Aliase | `theme/vscode.css` | `--vscode-*` Variablen mappen auf Phase-9-Variablen |
| ✅ UI-Library | `src/lib/ui/` | Button, Card, Icon, Badge, StatusDot, Tooltip, Drawer, Tabs |
| ✅ Lucide Icons | — | `lucide-svelte` installiert, ersetzt Emojis im Chrome |
| ✅ StatusBar (22px) | `StatusBar.svelte` (NEU) | Token · Modell+Modus · Kosten · Phase, klickbarer Modus-Picker |
| ✅ Titlebar entruempelt | `+layout.svelte` | Logo + Stop + Schulungsmodus (Lucide) + Version, kein Status-Dot mehr |
| ✅ Sidebar (240px) | `Sidebar.svelte` (NEU) | Suche (Cmd+K), Sessions, Nav-Rail mit 4 Lucide-Icons (Activity/Database/Wrench/Settings) |
| ✅ ToolDrawer | `ToolDrawer.svelte` (NEU) | Rechts-Drawer (420px) mit internen Tabs fuer Activity/Memory/Tools/Settings, Esc-schliessbar |
| ✅ +page.svelte 2-spaltig | `+page.svelte` | 4-Pane-PaneForge-Layout aufgeloest, jetzt: Sidebar + ChatPanel (flex) + Drawer-Overlay |
| ✅ Hardgecodete Farben raus | 9 Komponenten | `#ef4444 #22c55e #eab308 #f59e0b #a855f7 #06b6d4 ...` durch `var(--status-*)` ersetzt |
| ✅ Status-Emojis durch StatusDot | `AgentView.svelte` | `🟢 🟡 ⚪ 🔴` durch `<StatusDot>` mit Pulse-Animation bei aktivem Agent |
| ✅ ChatPanel entkoppelt | `ChatPanel.svelte` | Header + ChatStatusBar entfernt (jetzt global) — kompakte Toolbar nur noch fuer Detach |
| ✅ Cmd+K global | `+page.svelte`, `QuickActions.svelte` | Globaler Listener oeffnet QuickActions; navigate-tab oeffnet Drawer-Sektion |
| ⏳ Komponenten-Pass (Emojis raus) | 13 Panels | Emojis in Tab-Labels und Action-Buttons noch sukzessive durch Lucide ersetzen |
---
## Technische Schulden
| Was | Prioritaet |

10
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"lucide-svelte": "^1.0.1",
"marked": "^18.0.0",
"mermaid": "^11.4.0",
"paneforge": "^1.0.2"
@ -3582,6 +3583,15 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View file

@ -28,6 +28,7 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"lucide-svelte": "^1.0.1",
"marked": "^18.0.0",
"mermaid": "^11.4.0",
"paneforge": "^1.0.2"

View file

@ -1,53 +1,131 @@
/* Claude Desktop — Basis-Styles (AWL Dark Theme) */
/* Claude Desktop Design-System (Phase 9)
*
* Single Source of Truth fuer Farben, Typografie, Spacing, Radius.
* Jede Komponente nutzt ausschliesslich diese Variablen keine
* hardgecodeten Hex-Werte, keine Inline-Padding-Pixelwerte.
*
* Aufbau:
* 1. Farb-Palette (4 Graustufen + 1 Akzent + 4 Status)
* 2. Typografie (1 Sans + 1 Mono, 5 Schriftgroessen)
* 3. Spacing (4-Punkt-Grid, 6 Stufen)
* 4. Border-Radius (2 Stufen)
* 5. Schatten & Animationen
* 6. Reset + Basis
*/
:root {
/* AWL Dark Farbschema — basierend auf KDE Breeze Dark */
--bg-primary: #202326; /* Body/Window BG */
--bg-secondary: #272c31; /* Top-Menü / Header BG */
--bg-tertiary: #292c30; /* Button/Tabellen-Header BG */
--bg-input: #141618; /* Input-Felder / ungerade Zeilen */
--bg-hover: #2a2e33; /* Hover-State */
--bg-selected: #1e5774; /* Ausgewählte Elemente */
/* === 1. Farben (Dark, Default) ============================== */
--text-primary: #cccccc; /* Haupttext (VSCode Dark+ Niveau) */
--text-secondary: #a1a9b1; /* Sekundärtext (ForegroundInactive) */
--text-heading: #d4d4d4; /* Titel/Überschriften */
/* Grautoene */
--bg-primary: #1a1a1a; /* Editor / Hauptbereich */
--bg-secondary: #242424; /* Sidebar / Panels */
--bg-tertiary: #2d2d2d; /* Hover, Card-BG, Selected */
--bg-input: #1f1f1f; /* Input-Felder */
--bg-hover: #2a2a2a; /* dezenter Hover-State */
--bg-selected: #094771; /* aktive Auswahl in Listen */
--accent: #3daee9; /* DecorationFocus (Breeze Blau) */
--accent-hover: #4dbdf9; /* Heller beim Hover */
--link: #1d99f3; /* ForegroundLink */
/* Borders */
--border: #3e3e42; /* Standard-Trenner */
--border-strong: #525257; /* Aktive / Focus */
--success: #27ae60; /* Grün (KDE Breeze) */
--warning: #f67400; /* Orange (KDE Breeze) */
--error: #da4453; /* Rot (KDE Breeze) */
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #9a9a9a;
--text-disabled: #5a5a5a;
--text-heading: #f0f0f0;
/* Rahmen */
--border: #3b3f44; /* Dezente Trennlinien */
--border-active: #3daee9; /* Aktive Elemente */
/* Akzent (1 Farbe) */
--accent: #007acc;
--accent-hover: #1177bb;
--accent-fg: #ffffff;
--focus: #007fd4;
--link: #3794ff;
/* Abstände */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Status (4 Farben) */
--status-success: #6a9955;
--status-warning: #f5a623;
--status-error: #f48771;
--status-info: #569cd6;
/* Border-Radius */
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 10px;
/* Diff-Farben (Code-Kontext) */
--diff-added-bg: rgba(106, 153, 85, 0.18);
--diff-removed-bg: rgba(244, 135, 113, 0.18);
--diff-added-fg: #6a9955;
--diff-removed-fg: #f48771;
/* Aliase fuer Altcode (deprecated, werden ueber Phase 9-12 entfernt) */
--success: var(--status-success);
--warning: var(--status-warning);
--error: var(--status-error);
/* === 2. Typografie ========================================= */
--font-sans: 'Inter', -apple-system, 'Segoe UI', 'Roboto', 'Noto Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Hack', 'Fira Code', 'Consolas', monospace;
--fs-xs: 11px; /* Metadaten, Timestamps, Status-Bar */
--fs-sm: 12px; /* Sidebar, sekundaere UI */
--fs-md: 13px; /* Body, Chat, Code */
--fs-lg: 14px; /* UI-Header */
--fs-xl: 16px; /* Dialog-Titel */
--fw-normal: 400;
--fw-medium: 500;
--fw-semi: 600;
--lh-tight: 1.4;
--lh-normal: 1.5;
--lh-code: 1.6;
/* === 3. Spacing (4-Punkt-Grid) ============================= */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
/* Legacy-Aliase (werden in Komponenten-Pass entfernt) */
--spacing-xs: var(--sp-1);
--spacing-sm: var(--sp-2);
--spacing-md: var(--sp-4);
--spacing-lg: var(--sp-5);
--spacing-xl: var(--sp-6);
/* === 4. Border-Radius (max. 2 Werte) ======================= */
--r-sm: 3px; /* Inputs, Buttons */
--r-md: 6px; /* Karten, Modals, Drawer */
/* Legacy-Aliase */
--radius-sm: var(--r-sm);
--radius-md: var(--r-md);
--radius-lg: var(--r-md);
/* === 5. Schatten & Animationen ============================= */
/* Schatten */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
/* Font */
--font-mono: 'Hack', 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Noto Sans', 'Inter', system-ui, sans-serif;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 120ms;
--dur-base: 200ms;
/* === Layout-Konstanten ===================================== */
--titlebar-height: 36px;
--statusbar-height: 22px;
--sidebar-width: 240px;
--sidebar-collapsed: 48px;
--drawer-width: 360px;
--tabbar-height: 32px;
}
* {
/* === 6. Reset + Basis ========================================= */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
@ -56,80 +134,71 @@
html, body {
height: 100%;
font-family: var(--font-sans);
font-size: var(--fs-md);
font-weight: var(--fw-normal);
line-height: var(--lh-normal);
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar — KDE-Style, dezent */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
}
/* Button-Reset */
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
/* Input-Reset */
input, textarea, select {
font-family: inherit;
border: 1px solid var(--border);
background: var(--bg-input);
font-size: var(--fs-md);
color: var(--text-primary);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
transition: border-color 0.2s ease;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: var(--sp-2) var(--sp-3);
transition: border-color var(--dur-fast) var(--ease);
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
border-color: var(--focus);
}
/* Code-Blöcke */
code, pre {
font-family: var(--font-mono);
background: var(--bg-input);
border-radius: var(--radius-sm);
font-size: var(--fs-md);
}
code {
padding: 0.1em 0.3em;
font-size: 0.9em;
background: var(--bg-tertiary);
border-radius: var(--r-sm);
padding: 1px 4px;
}
pre {
padding: var(--spacing-md);
overflow-x: auto;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: var(--sp-3);
overflow-x: auto;
line-height: var(--lh-code);
}
/* Überschriften */
h1, h2, h3, h4 {
color: var(--text-heading);
font-weight: var(--fw-semi);
}
/* Links */
h1 { font-size: var(--fs-xl); }
h2 { font-size: var(--fs-lg); }
h3 { font-size: var(--fs-md); }
h4 { font-size: var(--fs-sm); }
a {
color: var(--link);
text-decoration: none;
@ -139,27 +208,56 @@ a:hover {
text-decoration: underline;
}
/* Animationen */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
/* Scrollbars (subtil, VS-Code-Look) */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.4);
border-radius: 5px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
background-clip: content-box;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Selection */
::selection {
background: var(--bg-selected);
color: var(--text-primary);
}
/* Animationen */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.animate-pulse { animation: pulse 2s ease-in-out infinite; }
.animate-spin { animation: spin 1s linear infinite; }
.animate-fade-in { animation: fade-in var(--dur-base) var(--ease); }
/* Utility-Klassen (nur Selten genutzt — bevorzugt Komponenten) */
.text-secondary { color: var(--text-secondary); }
.text-mono { font-family: var(--font-mono); }
.tabular-nums { font-variant-numeric: tabular-nums; }

View file

@ -2,6 +2,15 @@
import { agents, selectedAgentId, agentCount, agentTree, agentMode, type AgentTreeNode } from '$lib/stores/app';
import type { Agent } from '$lib/stores/app';
import { derived } from 'svelte/store';
import StatusDot from '$lib/ui/StatusDot.svelte';
// Mapping von Agent-Status auf StatusDot-Status
function dotStatus(s: Agent['status']): 'success' | 'warning' | 'idle' | 'error' {
if (s === 'active') return 'success';
if (s === 'waiting') return 'warning';
if (s === 'stopped') return 'error';
return 'idle';
}
// Filter-State persistent in localStorage speichern
let onlyActive = $state(localStorage.getItem('agentFilter_onlyActive') === 'true');
@ -29,13 +38,7 @@
auto: { label: '🤖 Auto', cssClass: 'badge-auto' },
};
// Status-Icons
const statusIcons: Record<Agent['status'], string> = {
active: '🟢',
waiting: '🟡',
idle: '⚪',
stopped: '🔴'
};
// (Status-Icons werden ueber <StatusDot /> dargestellt — Phase 9)
// Typ-Namen (erweitert für alle Agent-Typen)
const typeNames: Record<Agent['type'], string> = {
@ -125,7 +128,7 @@
<div class="agent-content">
<div class="agent-main">
<span class="agent-status">{statusIcons[agent.status]}</span>
<span class="agent-status"><StatusDot status={dotStatus(agent.status)} pulse={agent.status === 'active'} /></span>
<span class="agent-type-icon" title={typeNames[agent.type]}>{typeIcons[agent.type]}</span>
<span class="agent-type">{typeNames[agent.type]}</span>
{#if agent.model}
@ -198,7 +201,7 @@
<div class="detail-row">
<span class="detail-label">Status:</span>
<span class="detail-value">{statusIcons[selectedAgent.status]} {selectedAgent.status}</span>
<span class="detail-value"><StatusDot status={dotStatus(selectedAgent.status)} pulse={selectedAgent.status === 'active'} /> {selectedAgent.status}</span>
</div>
<div class="detail-row">
<span class="detail-label">Aufgabe:</span>
@ -443,17 +446,17 @@
}
.delegation-badge.badge-handlanger {
color: #f59e0b;
color: var(--status-warning);
background: rgba(245, 158, 11, 0.15);
}
.delegation-badge.badge-experten {
color: #a855f7;
color: var(--accent);
background: rgba(168, 85, 247, 0.15);
}
.delegation-badge.badge-auto {
color: #06b6d4;
color: var(--status-info);
background: rgba(6, 182, 212, 0.15);
}

View file

@ -262,6 +262,6 @@
}
.btn-save:hover {
background: #f59e0b;
background: var(--status-warning);
}
</style>

View file

@ -10,6 +10,8 @@
import DiffView from './DiffView.svelte';
import FileMention from './FileMention.svelte';
import QuickActions from './QuickActions.svelte';
import MessageList from './MessageList.svelte';
// ChatStatusBar entfernt (Phase 9): wandert in globale StatusBar im +layout
// Props
let { detached = false }: { detached?: boolean } = $props();
@ -893,6 +895,30 @@
}
}
// Streaming-Message-ID fuer MessageList (ohne findLast — kompatibel)
const streamingMessageId = $derived.by(() => {
if (!$isProcessing) return null;
for (let i = $messages.length - 1; i >= 0; i--) {
const m = $messages[i];
if (m.role === 'assistant' && !m.content) return m.id;
}
return null;
});
// Wrapper fuer MessageList: ID → Message bzw. Index aufloesen
function handleEditById(id: string) {
const m = $messages.find(x => x.id === id);
if (m) startEdit(m);
}
function handleRegenerateById(id: string) {
const idx = $messages.findIndex(x => x.id === id);
if (idx >= 0) regenerateResponse(idx);
}
function handleRewindById(id: string) {
// Rewind ist heute noch nicht ueber UI verdrahtet — Platzhalter
console.log('Rewind angefordert fuer Message', id);
}
// Edit-Funktionen
function startEdit(message: Message) {
editingMessageId = message.id;
@ -1092,186 +1118,51 @@
</div>
{/if}
<div class="chat-header">
<h2>💬 Chat</h2>
<div class="header-stats">
<span class="msg-count">{$messages.length} Nachrichten</span>
<span class="token-count" class:warning={estimatedTokens > 20000} class:danger={estimatedTokens > TOKEN_WARNING_THRESHOLD}>
~{(estimatedTokens / 1000).toFixed(1)}k Token
</span>
{#if !detached}
<button class="detach-btn" onclick={() => invoke('chat_window_open')} title="Chat herausloesen">&#x29C9;</button>
{:else}
<button class="detach-btn" onclick={() => invoke('chat_window_close')} title="Zurueck ins Hauptfenster">&#x29C7;</button>
{/if}
</div>
</div>
<div class="chat-messages" bind:this={messagesContainer} use:addCopyButtons>
{#if $messages.length === 0}
<div class="empty-state">
<div class="empty-icon">🤖</div>
<p>Starte eine Konversation mit Claude.</p>
<p class="hint">Enter/Ctrl+Enter = Senden, Shift+Enter = Neue Zeile, Ctrl+K = Quick-Actions, Escape = Stopp</p>
</div>
<!-- Phase 9: Lokaler Header entfernt — Brand+Stats sind in der globalen
Titlebar / StatusBar. Detach-Button als kleine Icon-Action im Tab-
Bereich (oben rechts), realisiert ueber kompakte Toolbar. -->
<div class="chat-toolbar">
{#if !detached}
<button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">&#x29C9;</button>
{:else}
{#each $messages as message, index}
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:editing={editingMessageId === message.id} class:queued={message.queued}>
<div class="message-header">
<span class="message-role" class:role-user={message.role === 'user'} class:role-assistant={message.role === 'assistant'} class:role-system={message.role === 'system'}>
{#if message.role === 'user' && message.queued}
<span class="role-dot queued"></span> Du <span class="role-tag">wartet</span>
{:else if message.role === 'user'}
<span class="role-dot user"></span> Du
{:else if message.role === 'assistant'}
<span class="role-dot assistant"></span> {#if message.model}{message.model.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}{:else}Claude{/if}
{:else}
<span class="role-dot system"></span> System
{/if}
</span>
<div class="message-actions">
{#if message.role === 'user' && !$isProcessing && editingMessageId !== message.id}
<button class="action-btn" onclick={() => startEdit(message)} title="Bearbeiten">✏️</button>
{/if}
{#if message.role === 'assistant' && !$isProcessing && isLastAssistantMessage(index) && message.content}
<button class="action-btn" onclick={() => regenerateResponse(index)} title="Antwort neu generieren">🔄</button>
{/if}
{#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>
{/if}
<span class="message-time">
{message.timestamp.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr
</span>
</div>
</div>
<div class="message-content">
{#if editingMessageId === message.id}
<textarea
class="edit-textarea"
bind:value={editingContent}
onkeydown={handleEditKeydown}
rows="3"
></textarea>
<div class="edit-actions">
<button class="edit-btn cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn confirm" onclick={confirmEdit} disabled={!editingContent.trim()}>
Speichern & Senden
</button>
</div>
{:else if message.role === 'assistant'}
{#if message.content}
{#if shouldCollapse(message.content, 'assistant') && !expandedMessages.includes(message.id) && !($isProcessing && isLastAssistantMessage(index))}
<div class="msg-collapsed">
{@html renderMarkdown(message.content.split('\n').slice(0, COLLAPSE_LINES_ASSISTANT).join('\n') + '\n...')}
</div>
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▼ Mehr anzeigen ({message.content.split('\n').length} Zeilen)
</button>
{:else}
{@html renderMarkdown(message.content)}
{#if shouldCollapse(message.content, 'assistant') && !($isProcessing && isLastAssistantMessage(index))}
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▲ Einklappen
</button>
{/if}
{/if}
{:else if $isProcessing}
<div class="activity-indicator">
{#if $currentTool}
<span class="activity-icon">{getToolIcon($currentTool.tool)}</span>
<span class="activity-label">{getToolLabel($currentTool.tool, $currentTool.input)}</span>
{:else}
<span class="activity-icon">{getPhaseIcon($processingPhase)}</span>
<span class="activity-label">{getPhaseLabel($processingPhase)}</span>
{/if}
<span class="activity-dots">
<span class="activity-dot"></span>
<span class="activity-dot"></span>
<span class="activity-dot"></span>
</span>
</div>
{/if}
{:else}
{#if message.role === 'user' && message.content && shouldCollapse(message.content, 'user') && !expandedMessages.includes(message.id)}
<div class="msg-collapsed">
{message.content.split('\n').slice(0, COLLAPSE_LINES_USER).join('\n')}{'\n...'}
</div>
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▼ Eingefügt: {message.content.split('\n').length} Zeilen
</button>
{:else}
{message.content}
{#if message.role === 'user' && message.content && shouldCollapse(message.content, 'user')}
<button class="expand-btn" onclick={() => toggleExpand(message.id)}>
▲ Einklappen
</button>
{/if}
{/if}
{/if}
</div>
</div>
{/each}
{/if}
{#if $isProcessing}
{@const lastMsg = $messages.at(-1)}
{@const hasEmptyAssistant = $messages.some(m => m.role === 'assistant' && !m.content)}
{#if !lastMsg || (lastMsg.role !== 'assistant' && !hasEmptyAssistant)}
<!-- Nur zeigen wenn nirgends eine leere assistant-message mit Activity-Indicator ist -->
<div class="message assistant typing-msg">
<div class="message-header">
<span class="message-role">{'\u{1F916}'} Claude</span>
</div>
<div class="message-content">
<div class="activity-indicator">
{#if $currentTool}
<span class="activity-icon">{getToolIcon($currentTool.tool)}</span>
<span class="activity-label">{getToolLabel($currentTool.tool, $currentTool.input)}</span>
{:else}
<span class="activity-icon">{getPhaseIcon($processingPhase)}</span>
<span class="activity-label">{getPhaseLabel($processingPhase)}</span>
{/if}
<span class="activity-dots">
<span class="activity-dot"></span>
<span class="activity-dot"></span>
<span class="activity-dot"></span>
</span>
</div>
</div>
</div>
{/if}
<button class="tool-btn" onclick={() => invoke('chat_window_close')} title="Zurück ins Hauptfenster">&#x29C7;</button>
{/if}
</div>
<!-- Pending Changes: DiffView mit Accept/Reject -->
{#if $pendingChanges.length > 0}
<div class="pending-changes">
<div class="pending-changes-header">
<span>📝 {$pendingChanges.length} Datei-Aenderung{$pendingChanges.length > 1 ? 'en' : ''}</span>
<button
class="btn-accept-all"
onclick={() => $pendingChanges.forEach((c) => acceptChange(c.toolId))}
>
✅ Alle behalten
<div class="chat-messages-wrap" bind:this={messagesContainer}>
<MessageList
{streamingMessageId}
onEdit={handleEditById}
onRegenerate={handleRegenerateById}
onRemember={openRememberDialog}
onRewind={handleRewindById}
/>
</div>
<!-- Edit-Mode Inline-Editor (legacy, wird nur aktiv wenn editingMessageId gesetzt) -->
{#if editingMessageId}
<div class="edit-overlay">
<textarea
class="edit-textarea"
bind:value={editingContent}
onkeydown={handleEditKeydown}
rows="3"
placeholder="Nachricht bearbeiten..."
></textarea>
<div class="edit-actions">
<button class="edit-btn cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn confirm" onclick={confirmEdit} disabled={!editingContent.trim()}>
Speichern &amp; Senden
</button>
</div>
{#each $pendingChanges as change (change.toolId)}
<DiffView
oldText={change.contentBefore}
newText={change.contentAfter}
filename={change.filePath}
interactive={true}
toolId={change.toolId}
onAccept={acceptChange}
onReject={rejectChange}
/>
{/each}
</div>
{/if}
<!-- Pending-Changes-Block entfernt (Phase 8): DiffView wird jetzt inline
in ToolCardEdit innerhalb der Assistant-Message gerendert. Backend-Logik
(acceptChange/rejectChange) bleibt in dieser Datei und wird ueber
den pendingChanges-Store von ToolCardEdit aufgerufen. -->
<div class="chat-input">
<CommandPalette
bind:this={commandPaletteRef}
@ -1351,6 +1242,8 @@
</button>
</div>
</div>
<!-- ChatStatusBar entfernt (Phase 9): globale StatusBar in +layout uebernimmt -->
</div>
<!-- Quick-Actions Palette (Ctrl+K) -->
@ -1484,29 +1377,35 @@
min-height: 0; /* Flex-Kinder korrekt begrenzen (WebKitGTK) */
}
.chat-header {
.chat-toolbar {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
padding: var(--sp-1) var(--sp-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary);
border-bottom: 1px solid var(--border);
min-height: 28px;
gap: var(--sp-1);
}
.chat-header h2 {
font-size: 0.875rem;
font-weight: 600;
}
.header-stats {
display: flex;
gap: var(--spacing-sm);
.tool-btn {
display: inline-flex;
align-items: center;
}
.msg-count {
font-size: 0.625rem;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--r-sm);
color: var(--text-secondary);
background: transparent;
font-size: 14px;
line-height: 1;
border: 0;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.tool-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.token-count {
@ -1520,7 +1419,7 @@
.token-count.warning {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
color: var(--status-warning);
}
.token-count.danger {
@ -1546,16 +1445,59 @@
border-color: var(--accent, #6c8aff);
}
.chat-messages {
.chat-messages-wrap {
flex: 1;
min-height: 0; /* Flex-Overflow korrekt begrenzen */
overflow-y: auto;
padding: var(--spacing-md);
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
position: relative; /* fuer Back-to-Bottom-Button absolute */
background: var(--vscode-editor-background);
}
.edit-overlay {
padding: 8px 12px;
background: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-input-border);
display: flex;
flex-direction: column;
gap: 6px;
}
.edit-textarea {
width: 100%;
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-focusBorder);
border-radius: 3px;
padding: 6px 8px;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.edit-btn {
font-size: 11.5px;
padding: 4px 10px;
border-radius: 2px;
}
.edit-btn.cancel {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-foreground);
}
.edit-btn.cancel:hover { background: var(--vscode-button-secondaryHoverBackground); }
.edit-btn.confirm {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.edit-btn.confirm:hover { background: var(--vscode-button-hoverBackground); }
.edit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.empty-state {
display: flex;
flex-direction: column;
@ -1593,7 +1535,7 @@
.message.queued {
opacity: 0.6;
border-left: 3px solid var(--accent, #f59e0b);
border-left: 3px solid var(--accent);
animation: pulse-queued 2s ease-in-out infinite;
}
@ -1640,7 +1582,7 @@
}
.role-dot.assistant {
background: #a78bfa;
background: var(--accent);
box-shadow: 0 0 6px rgba(167, 139, 250, 0.4);
}
@ -1649,7 +1591,7 @@
}
.role-dot.queued {
background: #f59e0b;
background: var(--status-warning);
animation: pulse-dot 1.5s infinite;
}
@ -1663,7 +1605,7 @@
padding: 0.1rem 0.35rem;
border-radius: 3px;
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
color: var(--status-warning);
font-weight: 500;
}
@ -2149,7 +2091,7 @@
.mic-button.recording {
background: rgba(239, 68, 68, 0.15);
border-color: #ef4444;
border-color: var(--status-error);
animation: pulse-recording 1.5s ease-in-out infinite;
}
@ -2163,7 +2105,7 @@
}
.mic-icon.recording {
color: #ef4444;
color: var(--status-error);
}
.audio-level {
@ -2194,7 +2136,7 @@
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: #ef4444;
color: var(--status-error);
}
/* Modus-Anzeige im Eingabebereich */
@ -2225,7 +2167,7 @@
.mode-indicator.mode-experten {
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.3);
color: #a78bfa;
color: var(--accent);
}
.mode-indicator.mode-auto {
@ -2475,7 +2417,7 @@
}
.modal-header.warning h3 {
color: #eab308;
color: var(--status-warning);
}
.warning-text {

View file

@ -0,0 +1,162 @@
<script lang="ts">
// Chat-Statusbar (Phase 8)
// Eine Zeile unter dem Input mit Token-Auslastung, Modus, Modell, Phase.
// Klickbar fuer Modus-Wechsel.
import { agentMode, contextUsage, contextPercent, currentModel, type AgentMode } from '$lib/stores';
import { processingPhase } from '$lib/stores/events';
import { invoke } from '@tauri-apps/api/core';
const modes: { id: AgentMode; icon: string; label: string }[] = [
{ id: 'solo', icon: '🎯', label: 'Solo' },
{ id: 'handlanger', icon: '👷', label: 'Handlanger' },
{ id: 'experten', icon: '🎓', label: 'Experten' },
{ id: 'auto', icon: '🤖', label: 'Auto' },
];
let menuOpen = $state(false);
function pickMode(m: AgentMode) {
agentMode.set(m);
menuOpen = false;
invoke('set_agent_mode', { mode: m }).catch((e) => console.debug('set_agent_mode failed:', e));
}
const currentMode = $derived(modes.find(m => m.id === $agentMode) || modes[0]);
const tokenStr = $derived(
`${$contextUsage.inputTokens.toLocaleString('de-DE')} t (${$contextPercent}%)`
);
const phaseLabel = $derived.by(() => {
switch ($processingPhase) {
case 'thinking': return 'denkt nach';
case 'streaming': return 'streamt';
case 'tool-use': return 'nutzt Tool';
case 'subagent': return 'Subagent aktiv';
default: return '';
}
});
const modelShort = $derived.by(() => {
const m = $currentModel || '';
return m.replace('claude-', '').replace(/-\d{8}$/, '');
});
</script>
<div class="status-bar">
<span class="item token" class:warn={$contextPercent > 70} class:danger={$contextPercent > 90}>
{tokenStr}
</span>
<span class="sep">·</span>
<button
class="item mode vscode-badge clickable"
onclick={() => (menuOpen = !menuOpen)}
title="Agent-Modus wechseln"
>
<span>{currentMode.icon}</span>
<span>{currentMode.label}</span>
{#if phaseLabel}
<span class="phase-suffix">{phaseLabel}</span>
{/if}
</button>
{#if modelShort}
<span class="sep">·</span>
<span class="item model" title="Aktives Modell">{modelShort}</span>
{/if}
<span class="hint">⏎ senden · ⇧⏎ Zeilenumbruch · / Befehle · @ Datei</span>
{#if menuOpen}
<div class="mode-menu" role="menu">
{#each modes as m}
<button
class="mode-item"
class:active={m.id === $agentMode}
onclick={() => pickMode(m.id)}
>
<span>{m.icon}</span>
<span>{m.label}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.status-bar {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-sideBar-background);
border-top: 1px solid var(--vscode-input-border);
min-height: 24px;
}
.item { display: inline-flex; align-items: center; gap: 4px; }
.sep { opacity: 0.5; }
.token { font-variant-numeric: tabular-nums; }
.token.warn { color: var(--vscode-warningForeground); }
.token.danger { color: var(--vscode-errorForeground); }
.mode {
font-size: 11px;
padding: 1px 7px;
}
.phase-suffix {
color: var(--vscode-progressBar-background);
font-style: italic;
margin-left: 3px;
}
.model {
font-family: var(--font-mono);
font-size: 10.5px;
opacity: 0.9;
}
.hint {
margin-left: auto;
opacity: 0.6;
font-size: 10.5px;
white-space: nowrap;
}
.mode-menu {
position: absolute;
bottom: calc(100% + 4px);
left: 80px;
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
box-shadow: 0 4px 12px var(--vscode-widget-shadow);
min-width: 160px;
z-index: 50;
padding: 4px 0;
}
.mode-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 5px 10px;
font-size: 12px;
text-align: left;
color: var(--vscode-editor-foreground);
background: transparent;
}
.mode-item:hover {
background: var(--vscode-list-hoverBackground);
}
.mode-item.active {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-button-foreground);
}
</style>

View file

@ -352,10 +352,10 @@
min-width: 20px;
text-align: center;
}
.stat.safe { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.stat.moderate { background: rgba(234, 179, 8, 0.2); color: #eab308; }
.stat.critical { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.stat.blocked { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; }
.stat.safe { background: rgba(106, 153, 85, 0.18); color: var(--status-success); }
.stat.moderate { background: rgba(245, 166, 35, 0.18); color: var(--status-warning); }
.stat.critical { background: rgba(244, 135, 113, 0.18); color: var(--status-error); }
.stat.blocked { background: var(--bg-tertiary); color: var(--text-secondary); }
/* Tabs */
.tabs {
@ -433,10 +433,10 @@
border-left: 3px solid var(--text-secondary);
}
.check-item.risk-safe { border-left-color: #22c55e; }
.check-item.risk-moderate { border-left-color: #eab308; }
.check-item.risk-critical { border-left-color: #ef4444; }
.check-item.risk-blocked { border-left-color: #8b5cf6; }
.check-item.risk-safe { border-left-color: var(--status-success); }
.check-item.risk-moderate { border-left-color: var(--status-warning); }
.check-item.risk-critical { border-left-color: var(--status-error); }
.check-item.risk-blocked { border-left-color: var(--text-secondary); }
.check-header {
display: flex;
@ -476,7 +476,7 @@
font-size: 0.65rem;
font-weight: 600;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
color: var(--status-success);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
@ -634,12 +634,12 @@
.tag.tool {
background: rgba(96, 165, 250, 0.15);
color: #60a5fa;
color: var(--status-info);
}
.tag.count {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
color: var(--status-warning);
}
.btn-delete {

View file

@ -154,7 +154,7 @@
.status.connected {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
color: var(--status-success);
}
.error {

View file

@ -0,0 +1,375 @@
<script lang="ts">
// Eine einzelne Chat-Message (Phase 8: VS-Code-Look)
//
// Layout: Avatar (Kreis 24px) links, Content rechts.
// User + Assistant beide linksbuendig — wie in der Claude-Code-Extension.
//
// Hover-Actions rechts oben: Edit (User), Regenerate (letzte Assistant),
// Copy, Das-merken, Rewind (Assistant).
import { messages, type Message, type InlineToolCall } from '$lib/stores';
import { processingPhase } from '$lib/stores/events';
import { renderMarkdown } from '$lib/utils/markdown';
import ToolCardAuto from './ToolCardAuto.svelte';
interface Props {
message: Message;
isLast?: boolean;
isStreaming?: boolean;
onEdit?: (id: string) => void;
onRegenerate?: (id: string) => void;
onRemember?: (m: Message) => void;
onRewind?: (id: string) => void;
}
let {
message,
isLast = false,
isStreaming = false,
onEdit,
onRegenerate,
onRemember,
onRewind,
}: Props = $props();
const userInitial = 'E';
const avatarChar = $derived.by(() => {
if (message.role === 'user') return userInitial;
if (message.role === 'system') return '!';
return '✱'; // ✱ Spark
});
const roleLabel = $derived.by(() => {
if (message.role === 'user') return 'Du';
if (message.role === 'system') return 'System';
return 'Claude';
});
const timeStr = $derived(
new Date(message.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
);
// Streaming-Sub-Label aus processingPhase
const phaseLabel = $derived.by(() => {
if (!isStreaming) return '';
switch ($processingPhase) {
case 'thinking': return 'denkt nach …';
case 'streaming': return 'streamt …';
case 'tool-use': return 'nutzt Tool …';
case 'subagent': return 'Subagent aktiv …';
default: return '';
}
});
// Klassische Hover-Actions
let copied = $state(false);
async function copyContent() {
try {
await navigator.clipboard.writeText(message.content);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch { /* ignore */ }
}
const showRegenerate = $derived(message.role === 'assistant' && isLast && onRegenerate);
const showEdit = $derived(message.role === 'user' && onEdit);
const showRewind = $derived(message.role === 'assistant' && onRewind);
const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []);
// Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung,
// kein MutationObserver — verhindert OOM bei langem Streaming)
let contentEl: HTMLDivElement | null = null;
function decorateCodeBlocks() {
if (!contentEl) return;
const wrappers = contentEl.querySelectorAll('.code-block-wrapper:not([data-copy-added])');
wrappers.forEach((wrapper) => {
wrapper.setAttribute('data-copy-added', 'true');
const lang = wrapper.getAttribute('data-lang') || '';
const codeEl = wrapper.querySelector('code');
const codeText = codeEl?.textContent || '';
const header = document.createElement('div');
header.className = 'code-header';
if (lang) {
const langSpan = document.createElement('span');
langSpan.className = 'code-lang';
langSpan.textContent = lang;
header.appendChild(langSpan);
}
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.title = 'Code kopieren';
btn.innerHTML = '📋';
btn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeText);
btn.innerHTML = '✓';
setTimeout(() => (btn.innerHTML = '📋'), 1500);
} catch { /* ignore */ }
};
header.appendChild(btn);
wrapper.insertBefore(header, wrapper.firstChild);
});
}
$effect(() => {
void message.content; // bei Content-Aenderung neu dekorieren
queueMicrotask(decorateCodeBlocks);
});
</script>
<article class="msg" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:queued={message.queued}>
<div class="avatar" aria-hidden="true">{avatarChar}</div>
<div class="body">
<header class="msg-header">
<span class="role">{roleLabel}</span>
<span class="time">· {timeStr}</span>
{#if message.queued}
<span class="queued-tag">wartet</span>
{/if}
{#if isStreaming && phaseLabel}
<span class="phase">· {phaseLabel}</span>
{/if}
<span class="actions">
{#if showEdit}
<button class="act" title="Bearbeiten" onclick={() => onEdit?.(message.id)}>✏️</button>
{/if}
{#if showRegenerate}
<button class="act" title="Neu generieren" onclick={() => onRegenerate?.(message.id)}>🔄</button>
{/if}
<button class="act" title={copied ? 'Kopiert' : 'Kopieren'} onclick={copyContent}>
{copied ? '✓' : '📋'}
</button>
{#if onRemember}
<button class="act" title="Das merken" onclick={() => onRemember?.(message)}>💡</button>
{/if}
{#if showRewind}
<button class="act" title="Hierhin zuruecksetzen" onclick={() => onRewind?.(message.id)}>↶</button>
{/if}
</span>
</header>
{#if message.content}
<div class="content" bind:this={contentEl}>
{@html renderMarkdown(message.content)}
{#if isStreaming}
<span class="cursor"></span>
{/if}
</div>
{:else if isStreaming}
<div class="content faint"></div>
{/if}
{#if toolCalls.length > 0}
<div class="tool-calls">
{#each toolCalls as call (call.id)}
<ToolCardAuto {call} />
{/each}
</div>
{/if}
</div>
</article>
<style>
.msg {
display: grid;
grid-template-columns: 28px 1fr;
column-gap: 10px;
padding: 10px 14px;
font-size: 13px;
line-height: 1.55;
color: var(--vscode-editor-foreground);
border-bottom: 1px solid transparent;
}
.msg + .msg { margin-top: 0; }
.msg.user {
background: transparent;
}
.msg.assistant {
background: rgba(255, 255, 255, 0.012);
}
.msg.system {
background: rgba(244, 135, 113, 0.06);
border-left: 2px solid var(--vscode-errorForeground);
}
.msg.queued {
opacity: 0.7;
border-left: 2px solid var(--vscode-warningForeground);
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
font-family: var(--font-sans);
user-select: none;
margin-top: 1px;
}
.msg.user .avatar {
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.msg.assistant .avatar {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.msg.system .avatar {
background: var(--vscode-errorForeground);
color: #1e1e1e;
}
.body { min-width: 0; }
.msg-header {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
margin-bottom: 2px;
}
.role {
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.time { color: var(--vscode-descriptionForeground); }
.phase {
color: var(--vscode-progressBar-background);
font-style: italic;
}
.queued-tag {
background: var(--vscode-warningForeground);
color: #1e1e1e;
font-size: 10px;
font-weight: 700;
padding: 0 6px;
border-radius: 2px;
margin-left: 4px;
}
.actions {
margin-left: auto;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.12s;
}
.msg:hover .actions { opacity: 1; }
.act {
font-size: 11px;
padding: 1px 5px;
border-radius: 2px;
background: transparent;
color: var(--vscode-descriptionForeground);
}
.act:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
.content {
font-size: 13.5px;
line-height: 1.55;
word-wrap: break-word;
}
.content.faint { color: var(--vscode-descriptionForeground); }
/* Markdown global styles innerhalb .content — kommen aus app.css fuer
pre/code; wir setzen nur ein paar Anpassungen fuer den VS-Code-Look. */
.content :global(p) { margin: 4px 0; }
.content :global(ul), .content :global(ol) { margin: 4px 0 4px 20px; }
.content :global(h1), .content :global(h2), .content :global(h3) {
margin: 10px 0 4px 0;
font-weight: 600;
}
.content :global(h1) { font-size: 16px; }
.content :global(h2) { font-size: 14.5px; }
.content :global(h3) { font-size: 13.5px; }
.content :global(.code-block-wrapper) {
background: var(--vscode-terminal-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
margin: 6px 0;
overflow: hidden;
}
.content :global(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 10px;
background: rgba(0, 0, 0, 0.25);
font-size: 10.5px;
gap: 6px;
}
.content :global(.code-lang) {
color: var(--vscode-descriptionForeground);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.content :global(.copy-btn) {
margin-left: auto;
padding: 1px 6px;
background: transparent;
border: 1px solid var(--vscode-input-border);
border-radius: 2px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
cursor: pointer;
}
.content :global(.copy-btn:hover) {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.content :global(.code-block-wrapper pre) {
margin: 0;
padding: 6px 10px;
background: transparent;
border: 0;
font-size: 12px;
line-height: 1.55;
overflow-x: auto;
}
.content :global(code) {
font-family: var(--font-mono);
background: rgba(255, 255, 255, 0.05);
font-size: 12.5px;
}
.content :global(.thinking-inline) {
display: block;
margin: 6px 0;
padding: 6px 10px;
background: rgba(99, 102, 241, 0.06);
border-left: 3px solid rgba(99, 102, 241, 0.4);
border-radius: 2px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.content :global(.thinking-label) { margin-right: 4px; }
.content :global(a) { color: var(--link); }
.cursor {
display: inline-block;
animation: blink 1s steps(2, start) infinite;
color: var(--vscode-progressBar-background);
margin-left: 1px;
}
@keyframes blink {
to { visibility: hidden; }
}
.tool-calls {
margin-top: 4px;
}
</style>

View file

@ -0,0 +1,134 @@
<script lang="ts">
// Scrollbarer Container fuer alle Messages.
// Smart-Sticky-Scroll: Wenn der User selbst gescrollt hat, springt das
// Auto-Scroll nicht mehr zum Ende. Stattdessen erscheint ein
// Back-to-Bottom-Button.
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
import { tick } from 'svelte';
import MessageItem from './Message.svelte';
interface Props {
streamingMessageId?: string | null;
onEdit?: (id: string) => void;
onRegenerate?: (id: string) => void;
onRemember?: (m: ChatMessage) => void;
onRewind?: (id: string) => void;
}
let {
streamingMessageId = null,
onEdit,
onRegenerate,
onRemember,
onRewind,
}: Props = $props();
let container: HTMLDivElement | null = null;
let userScrolledUp = $state(false);
let scrollPending = false;
function checkScroll() {
if (!container) return;
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
const next = distance > 100;
if (next !== userScrolledUp) userScrolledUp = next;
}
async function scrollToBottom(force = false) {
if (!container) return;
if (!force && userScrolledUp) return;
if (scrollPending) return;
scrollPending = true;
await tick();
if (container) container.scrollTop = container.scrollHeight;
scrollPending = false;
}
function snapToBottom() {
userScrolledUp = false;
scrollToBottom(true);
}
// Auto-Scroll bei neuen Messages oder neuem Streaming-Token
// (untracked() verhindert dass userScrolledUp-Aenderungen erneut feuern)
$effect(() => {
// Nur diese beiden als Dependencies tracken
const _msgs = $messages.length;
const _proc = $isProcessing;
// Letzten Content-Length tracken, damit Streaming-Updates feuern
const _lastLen = $messages[$messages.length - 1]?.content?.length ?? 0;
void _msgs; void _proc; void _lastLen;
scrollToBottom();
});
</script>
<div class="message-list" bind:this={container} onscroll={checkScroll}>
{#each $messages as msg, i (msg.id)}
<MessageItem
message={msg}
isLast={i === $messages.length - 1}
isStreaming={msg.id === streamingMessageId}
{onEdit}
{onRegenerate}
{onRemember}
{onRewind}
/>
{/each}
{#if $messages.length === 0}
<div class="empty">
<p class="empty-title">✱ Claude Code</p>
<p class="empty-hint">Stelle eine Frage, lass Code analysieren, oder tippe <code>/</code> fuer Befehle.</p>
</div>
{/if}
</div>
{#if userScrolledUp}
<button class="scroll-bottom" onclick={snapToBottom} title="Zum Ende scrollen">
↓ Neue Nachrichten
</button>
{/if}
<style>
.message-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: var(--vscode-editor-background);
padding: 4px 0;
}
.empty {
padding: 60px 24px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.empty-title {
font-size: 18px;
color: var(--vscode-button-background);
margin-bottom: 6px;
}
.empty-hint {
font-size: 13px;
}
.empty code {
background: var(--vscode-input-background);
padding: 1px 5px;
border-radius: 2px;
font-family: var(--font-mono);
}
.scroll-bottom {
position: absolute;
right: 16px;
bottom: 90px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: 11.5px;
padding: 5px 10px;
border-radius: 12px;
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
z-index: 5;
}
.scroll-bottom:hover { background: var(--vscode-button-hoverBackground); }
</style>

View file

@ -395,7 +395,7 @@
}
.ratio-segment.output {
background: #22c55e;
background: var(--status-success);
}
.ratio-segment span {
@ -430,7 +430,7 @@
}
.latency-row .value.highlight {
color: #eab308;
color: var(--status-warning);
}
/* Session Liste */

View file

@ -328,7 +328,7 @@
.info-card.running {
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
color: var(--status-success);
}
.controls {

View file

@ -727,8 +727,8 @@
}
.command-badge.builtin { background: rgba(99, 102, 241, 0.15); color: #818cf8; }
.command-badge.skill { background: rgba(234, 179, 8, 0.15); color: #eab308; }
.command-badge.custom { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.command-badge.skill { background: rgba(234, 179, 8, 0.15); color: var(--status-warning); }
.command-badge.custom { background: rgba(34, 197, 94, 0.15); color: var(--status-success); }
.command-desc { font-size: 0.7rem; color: var(--text-secondary); }
.command-source { font-size: 0.6rem; color: var(--text-secondary); opacity: 0.5; font-family: var(--font-mono); }
@ -779,7 +779,7 @@
transition: color 0.15s;
}
.hook-toggle.enabled { color: var(--success, #22c55e); }
.hook-toggle.enabled { color: var(--status-success); }
.hook-text { display: flex; flex-direction: column; gap: 1px; }
.hook-name { font-size: 0.78rem; font-weight: 500; }
@ -814,8 +814,8 @@
.perm-pattern { font-weight: 500; flex: 1; font-family: var(--font-mono); font-size: 0.72rem; }
.perm-tool { color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.65rem; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 2px; }
.perm-status { font-size: 0.65rem; font-weight: 500; }
.perm-status.allowed { color: #22c55e; }
.perm-status.denied { color: #ef4444; }
.perm-status.allowed { color: var(--status-success); }
.perm-status.denied { color: var(--status-error); }
/* Empty State */
.empty-state {

View file

@ -0,0 +1,154 @@
<script lang="ts">
// Globale Sidebar (Phase 9)
//
// Layout (240px breit, kollabierbar):
// Top: Suche-Button (oeffnet QuickActions / Cmd+K)
// Mitte: Sessions-Liste (existing SessionList eingebettet)
// Bottom: Nav-Rail mit 4 Items (Aktivitaet / Speicher / Werkzeuge / Einstellungen)
// Klick oeffnet ToolDrawer rechts mit der jeweiligen Sektion.
import { Search, Activity, Database, Wrench, Settings } from 'lucide-svelte';
import { Icon, Tooltip } from '$lib/ui';
import SessionList from './SessionList.svelte';
export type DrawerSection = 'activity' | 'memory' | 'tools' | 'settings';
interface Props {
activeDrawer: DrawerSection | null;
onSearchOpen: () => void;
onDrawerToggle: (section: DrawerSection) => void;
}
let { activeDrawer, onSearchOpen, onDrawerToggle }: Props = $props();
const navItems: { id: DrawerSection; label: string; icon: typeof Activity }[] = [
{ id: 'activity', label: 'Aktivität', icon: Activity },
{ id: 'memory', label: 'Speicher', icon: Database },
{ id: 'tools', label: 'Werkzeuge', icon: Wrench },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
];
</script>
<aside class="sidebar">
<!-- Suche / Cmd+K -->
<button class="search-btn" onclick={onSearchOpen} title="Suchen (Ctrl+K)">
<Icon icon={Search} size={14} />
<span class="search-label">Suchen</span>
<kbd>Ctrl K</kbd>
</button>
<!-- Sessions-Liste -->
<div class="sessions">
<SessionList />
</div>
<!-- Nav-Rail -->
<nav class="nav-rail" aria-label="Werkzeug-Navigation">
{#each navItems as item (item.id)}
<Tooltip text={item.label} placement="right">
<button
class="nav-btn"
class:active={activeDrawer === item.id}
onclick={() => onDrawerToggle(item.id)}
aria-label={item.label}
aria-pressed={activeDrawer === item.id}
>
<Icon icon={item.icon} size={16} />
<span class="nav-label">{item.label}</span>
</button>
</Tooltip>
{/each}
</nav>
</aside>
<style>
.sidebar {
width: var(--sidebar-width);
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
flex-shrink: 0;
min-height: 0;
}
/* Suche */
.search-btn {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-secondary);
font-size: var(--fs-sm);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.search-btn:hover {
border-color: var(--border-strong);
color: var(--text-primary);
}
.search-label {
flex: 1;
text-align: left;
}
.search-btn kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 5px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--text-secondary);
}
/* Sessions */
.sessions {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Nav-Rail */
.nav-rail {
display: flex;
flex-direction: column;
padding: var(--sp-1);
gap: 2px;
border-top: 1px solid var(--border);
}
.nav-btn {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-2) var(--sp-3);
background: transparent;
border: 0;
border-radius: var(--r-sm);
color: var(--text-secondary);
font-size: var(--fs-sm);
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.nav-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-btn.active {
background: var(--bg-selected);
color: var(--accent-fg);
}
.nav-label {
flex: 1;
}
</style>

View file

@ -0,0 +1,209 @@
<script lang="ts">
// Globale Status-Bar (Phase 9) — 22px hoch, ersetzt den heutigen
// Footer in +layout.svelte mit seinen 6+ Stats und Pulse-Animationen.
//
// Anzeige (in dieser Reihenfolge, durch · getrennt):
// Token-Auslastung mit Faerbung ab 70 % / 90 %
// Modell + Agent-Modus (klickbar = Modus-Picker)
// Kosten der Session (in $ oder ¢)
// Verarbeitungs-Phase (nur wenn aktiv)
import { invoke } from '@tauri-apps/api/core';
import {
contextUsage,
contextPercent,
currentModel,
sessionStats,
agentMode,
type AgentMode,
} from '$lib/stores';
import { processingPhase } from '$lib/stores/events';
const modes: { id: AgentMode; label: string }[] = [
{ id: 'solo', label: 'Solo' },
{ id: 'handlanger', label: 'Handlanger' },
{ id: 'experten', label: 'Experten' },
{ id: 'auto', label: 'Auto' },
];
let menuOpen = $state(false);
function pickMode(m: AgentMode) {
agentMode.set(m);
menuOpen = false;
invoke('set_agent_mode', { mode: m }).catch((e) =>
console.debug('set_agent_mode failed:', e)
);
}
const currentModeLabel = $derived(
modes.find((m) => m.id === $agentMode)?.label ?? 'Solo'
);
const tokenStr = $derived(
`${$contextUsage.inputTokens.toLocaleString('de-DE')} t (${$contextPercent}%)`
);
const modelShort = $derived(
($currentModel || '').replace('claude-', '').replace(/-\d{8}$/, '') || ''
);
const phaseText = $derived.by(() => {
switch ($processingPhase) {
case 'thinking': return 'denkt';
case 'streaming': return 'streamt';
case 'tool-use': return 'Tool';
case 'subagent': return 'Subagent';
default: return '';
}
});
const costStr = $derived.by(() => {
const c = $sessionStats.totalCost ?? 0;
if (c === 0) return '';
if (c < 0.01) return `${(c * 100).toFixed(1)} ¢`;
return `${c.toFixed(3)} $`;
});
function tokenTone(): 'normal' | 'warn' | 'danger' {
if ($contextPercent > 90) return 'danger';
if ($contextPercent > 70) return 'warn';
return 'normal';
}
</script>
<div class="status-bar">
<span class="item token tabular-nums" data-tone={tokenTone()}>{tokenStr}</span>
<button
class="item mode"
title="Modus wechseln"
onclick={() => (menuOpen = !menuOpen)}
>
{#if modelShort}
<span class="model">{modelShort}</span>
<span class="dot-sep">·</span>
{/if}
<span>{currentModeLabel}</span>
</button>
{#if costStr}
<span class="dot-sep">·</span>
<span class="item cost tabular-nums" title="Kosten dieser Session">{costStr}</span>
{/if}
{#if phaseText}
<span class="dot-sep">·</span>
<span class="item phase">{phaseText}</span>
{/if}
<span class="spacer"></span>
<span class="hint">⏎ senden · ⇧⏎ Zeilenumbruch · / Befehle · @ Datei</span>
{#if menuOpen}
<div class="menu" role="menu">
{#each modes as m (m.id)}
<button
class="menu-item"
class:active={m.id === $agentMode}
onclick={() => pickMode(m.id)}
>
{m.label}
</button>
{/each}
</div>
{/if}
</div>
<style>
.status-bar {
position: relative;
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--statusbar-height);
padding: 0 var(--sp-3);
font-size: var(--fs-xs);
color: var(--text-secondary);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.item {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-xs);
line-height: 1;
}
.dot-sep {
opacity: 0.4;
}
.token[data-tone="warn"] { color: var(--status-warning); }
.token[data-tone="danger"] { color: var(--status-error); }
.mode {
background: transparent;
border: 0;
color: var(--text-secondary);
padding: 0 var(--sp-1);
border-radius: 2px;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.mode:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.model {
font-family: var(--font-mono);
opacity: 0.85;
}
.phase {
color: var(--accent);
font-style: italic;
}
.spacer { flex: 1; }
.hint {
opacity: 0.55;
font-size: 10.5px;
white-space: nowrap;
}
.menu {
position: absolute;
bottom: calc(100% + 4px);
left: 100px;
min-width: 140px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
box-shadow: var(--shadow-md);
padding: var(--sp-1) 0;
z-index: 50;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-sm);
color: var(--text-primary);
background: transparent;
}
.menu-item:hover {
background: var(--bg-hover);
}
.menu-item.active {
background: var(--bg-selected);
color: var(--accent-fg);
}
</style>

View file

@ -0,0 +1,167 @@
<script lang="ts">
// Inline Tool-Call-Karte fuer Assistant-Messages (Phase 8)
//
// Sieht aus wie die Tool-Karten in der Claude-Code-Extension fuer VS Code:
// [▾ 📖 Read · src/app.ts:45-80]
// <Inhalt>
//
// Inhalt wird per <slot /> von Spezialisierungen gefuellt
// (ToolCardRead/Edit/Bash/...). Die Basis kuemmert sich um Header,
// Collapse, Status-Animation, Akzentleiste links.
import { getToolMeta, getToolSubtitle } from '$lib/utils/toolCards';
import type { InlineToolCall } from '$lib/stores';
interface Props {
call: InlineToolCall;
// Wenn der aufrufende Component bewusst aufgeklappt starten will:
defaultOpen?: boolean;
}
let { call, defaultOpen }: Props = $props();
const meta = $derived(getToolMeta(call.tool));
const subtitle = $derived(getToolSubtitle(call.tool, call.input));
// Manueller Override des Auto-Collapse-Verhaltens
let userOverride = $state<boolean | null>(null);
function autoOpen(): boolean {
if (defaultOpen !== undefined) return defaultOpen;
if (call.status === 'running') return true;
if (call.status === 'error') return true;
return !meta.collapseWhenDone;
}
const isOpen = $derived(userOverride !== null ? userOverride : autoOpen());
function toggle() {
userOverride = !isOpen;
}
function statusClass(s: string): string {
if (s === 'running') return 'running';
if (s === 'error') return 'error';
return 'success';
}
</script>
<div class="tool-card vscode-card vscode-card-accent {statusClass(call.status)}">
<button class="card-header" onclick={toggle} aria-expanded={isOpen}>
<span class="chevron" class:open={isOpen}>▸</span>
<span class="icon">{meta.icon}</span>
<span class="tool-name">{meta.label}</span>
{#if subtitle}
<span class="subtitle">· {subtitle}</span>
{/if}
<span class="status-spacer"></span>
{#if call.status === 'running'}
<span class="status-dots" aria-label="laeuft">
<span></span><span></span><span></span>
</span>
{:else if call.status === 'error'}
<span class="status-icon error" title="Fehler"></span>
{:else}
<span class="status-icon ok" title="erledigt"></span>
{/if}
</button>
{#if isOpen}
<div class="card-body">
<slot {call} />
</div>
{/if}
</div>
<style>
.tool-card {
margin: 0.5rem 0;
font-size: 12px;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
width: 100%;
gap: 6px;
padding: 6px 10px;
background: transparent;
color: var(--vscode-editor-foreground);
text-align: left;
font-size: 12px;
line-height: 1.4;
}
.card-header:hover {
background: var(--vscode-list-hoverBackground);
}
.chevron {
display: inline-block;
font-size: 10px;
width: 10px;
color: var(--vscode-descriptionForeground);
transition: transform 0.12s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.icon {
font-size: 13px;
line-height: 1;
}
.tool-name {
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.subtitle {
color: var(--vscode-descriptionForeground);
font-family: var(--font-mono);
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.status-spacer {
flex: 1;
}
.status-icon {
font-size: 11px;
font-weight: 700;
}
.status-icon.ok { color: var(--vscode-successForeground); }
.status-icon.error { color: var(--vscode-errorForeground); }
.status-dots {
display: inline-flex;
gap: 3px;
}
.status-dots span {
width: 4px;
height: 4px;
background: var(--vscode-progressBar-background);
border-radius: 50%;
animation: dotPulse 1.2s ease-in-out infinite;
}
.status-dots span:nth-child(2) { animation-delay: 0.15s; }
.status-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes dotPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1); }
}
.card-body {
padding: 8px 10px 10px 10px;
border-top: 1px solid var(--vscode-input-border);
background: var(--vscode-editor-background);
}
</style>

View file

@ -0,0 +1,28 @@
<script lang="ts">
// Waehlt automatisch die passende Tool-Card-Spezialisierung
// auf Basis des Tool-Namens.
import ToolCardRead from './ToolCardRead.svelte';
import ToolCardEdit from './ToolCardEdit.svelte';
import ToolCardBash from './ToolCardBash.svelte';
import ToolCardGeneric from './ToolCardGeneric.svelte';
import { getToolMeta } from '$lib/utils/toolCards';
import type { InlineToolCall } from '$lib/stores';
interface Props {
call: InlineToolCall;
}
let { call }: Props = $props();
const kind = $derived(getToolMeta(call.tool).kind);
</script>
{#if kind === 'read'}
<ToolCardRead {call} />
{:else if kind === 'edit'}
<ToolCardEdit {call} />
{:else if kind === 'bash'}
<ToolCardBash {call} />
{:else}
<ToolCardGeneric {call} />
{/if}

View file

@ -0,0 +1,133 @@
<script lang="ts">
// Spezialisierung Bash / BashOutput
// Zeigt Kommando als Zeile + Terminal-Output (monospace, gescrollt).
import ToolCallCard from './ToolCallCard.svelte';
import type { InlineToolCall } from '$lib/stores';
interface Props {
call: InlineToolCall;
}
let { call }: Props = $props();
const command = $derived((call.input.command as string) || '');
const description = $derived((call.input.description as string) || '');
let copied = $state(false);
async function copyOutput() {
try {
await navigator.clipboard.writeText(call.result || '');
copied = true;
setTimeout(() => (copied = false), 1500);
} catch { /* ignore */ }
}
// Output kuerzen (Terminal kann beliebig lang werden)
const MAX_LINES = 50;
const allLines = $derived((call.result || '').split('\n'));
const visibleLines = $derived(allLines.slice(-MAX_LINES));
const hiddenCount = $derived(Math.max(0, allLines.length - MAX_LINES));
</script>
<ToolCallCard {call}>
{#snippet children()}
{#if command}
<div class="cmd-line">
<span class="prompt">$</span>
<span class="cmd">{command}</span>
</div>
{/if}
{#if description && description !== command}
<div class="desc">{description}</div>
{/if}
{#if call.status === 'running'}
<div class="placeholder">… Befehl laeuft …</div>
{:else if call.result}
<div class="actions">
<button class="action-btn" onclick={copyOutput}>
{copied ? '✓ Kopiert' : '📋 Output kopieren'}
</button>
</div>
{#if hiddenCount > 0}
<div class="more-hint">… +{hiddenCount} fruehere Zeilen ausgeblendet</div>
{/if}
<pre class="terminal" class:err={call.status === 'error'}><code>{visibleLines.join('\n')}</code></pre>
{/if}
{/snippet}
</ToolCallCard>
<style>
.cmd-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--vscode-editor-foreground);
display: flex;
gap: 8px;
margin-bottom: 4px;
word-break: break-all;
}
.prompt {
color: var(--vscode-successForeground);
user-select: none;
}
.cmd { white-space: pre-wrap; }
.desc {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-bottom: 6px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-bottom: 4px;
}
.action-btn {
font-size: 11px;
padding: 2px 8px;
color: var(--vscode-descriptionForeground);
border-radius: 2px;
background: transparent;
}
.action-btn:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
.terminal {
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
background: var(--vscode-terminal-background);
color: var(--vscode-terminal-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
padding: 8px 10px;
max-height: 300px;
overflow: auto;
white-space: pre;
}
.terminal.err {
border-color: var(--vscode-errorForeground);
}
.terminal code {
background: none;
padding: 0;
}
.placeholder {
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
font-family: var(--font-mono);
}
.more-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
margin-bottom: 4px;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
// Spezialisierung Edit/Write/MultiEdit/NotebookEdit
// Zeigt Diff-Vorschau (rot/gruen). Wenn ein passender Eintrag in
// pendingChanges existiert, wird die existierende DiffView mit
// Accept/Reject-Buttons gerendert.
import ToolCallCard from './ToolCallCard.svelte';
import DiffView from './DiffView.svelte';
import { invoke } from '@tauri-apps/api/core';
import { pendingChanges, type InlineToolCall, type FileChange } from '$lib/stores';
interface Props {
call: InlineToolCall;
}
let { call }: Props = $props();
const filePath = $derived((call.input.file_path || call.input.path || '') as string);
// Versuche, einen passenden Eintrag aus dem pendingChanges-Store zu finden
const pending = $derived<FileChange | undefined>(
$pendingChanges.find(c => c.toolId === call.id || c.filePath === filePath)
);
// Fallback fuer Edit-Tool ohne pendingChanges-Eintrag (z.B. wenn der
// Snapshot bereits akzeptiert wurde): einfache Vorher/Nachher-Anzeige
// aus den Tool-Inputs.
const oldStr = $derived((call.input.old_string as string) || (pending?.contentBefore ?? ''));
const newStr = $derived((call.input.new_string as string) || (call.input.content as string) || (pending?.contentAfter ?? ''));
async function handleAccept() {
if (pending) {
try {
await invoke('accept_change', { toolId: pending.toolId });
pendingChanges.update(list => list.filter(c => c.toolId !== pending.toolId));
} catch (e) {
console.error('Accept failed:', e);
}
}
}
async function handleReject() {
if (pending) {
try {
await invoke('reject_change', { toolId: pending.toolId });
pendingChanges.update(list => list.filter(c => c.toolId !== pending.toolId));
} catch (e) {
console.error('Reject failed:', e);
}
}
}
</script>
<ToolCallCard {call} defaultOpen={true}>
{#snippet children()}
{#if call.status === 'running'}
<div class="placeholder">… Datei wird geschrieben …</div>
{:else if call.status === 'error'}
<div class="error-msg">{call.result || 'Fehler beim Schreiben'}</div>
{:else if pending}
<DiffView
oldText={pending.contentBefore}
newText={pending.contentAfter}
filename={pending.filePath}
interactive={true}
toolId={pending.toolId}
onAccept={handleAccept}
onReject={handleReject}
/>
{:else if oldStr || newStr}
<DiffView
oldText={oldStr}
newText={newStr}
filename={filePath}
interactive={false}
/>
{:else}
<div class="placeholder">{filePath || 'Edit'}</div>
{/if}
{/snippet}
</ToolCallCard>
<style>
.placeholder {
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
font-family: var(--font-mono);
}
.error-msg {
color: var(--vscode-errorForeground);
font-family: var(--font-mono);
font-size: 11.5px;
white-space: pre-wrap;
}
</style>

View file

@ -0,0 +1,95 @@
<script lang="ts">
// Generische Tool-Card fuer alles, was keine eigene Spezialisierung hat
// (Grep, Glob, WebFetch, WebSearch, MCP-Tools, Task, TodoWrite, ...).
// Zeigt Input + Result als formatiertes JSON / Text.
import ToolCallCard from './ToolCallCard.svelte';
import type { InlineToolCall } from '$lib/stores';
interface Props {
call: InlineToolCall;
}
let { call }: Props = $props();
function formatInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
const inputStr = $derived(formatInput(call.input));
const showInput = $derived(Object.keys(call.input || {}).length > 0);
const MAX_RESULT_LINES = 30;
const allLines = $derived((call.result || '').split('\n'));
const visibleLines = $derived(allLines.slice(0, MAX_RESULT_LINES));
const hiddenCount = $derived(Math.max(0, allLines.length - MAX_RESULT_LINES));
</script>
<ToolCallCard {call}>
{#snippet children()}
{#if showInput}
<details class="input-details">
<summary>Input</summary>
<pre class="raw">{inputStr}</pre>
</details>
{/if}
{#if call.status === 'running'}
<div class="placeholder">… laeuft …</div>
{:else if call.result}
<pre class="raw" class:err={call.status === 'error'}>{visibleLines.join('\n')}</pre>
{#if hiddenCount > 0}
<div class="more-hint">… +{hiddenCount} weitere Zeilen</div>
{/if}
{/if}
{/snippet}
</ToolCallCard>
<style>
.input-details {
margin-bottom: 6px;
font-size: 11px;
}
.input-details summary {
cursor: pointer;
color: var(--vscode-descriptionForeground);
user-select: none;
}
.input-details summary:hover {
color: var(--vscode-editor-foreground);
}
.raw {
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.5;
background: var(--vscode-terminal-background);
color: var(--vscode-editor-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
padding: 6px 8px;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.raw.err {
color: var(--vscode-errorForeground);
border-color: var(--vscode-errorForeground);
}
.placeholder {
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
font-family: var(--font-mono);
}
.more-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
margin-top: 4px;
}
</style>

View file

@ -0,0 +1,129 @@
<script lang="ts">
// Spezialisierung Read/Glob/NotebookRead
// Zeigt Inhalt als Code-Block mit Zeilennummern, kollabsbar, Copy-Button.
import ToolCallCard from './ToolCallCard.svelte';
import type { InlineToolCall } from '$lib/stores';
interface Props {
call: InlineToolCall;
}
let { call }: Props = $props();
const filePath = $derived((call.input.file_path || call.input.path || '') as string);
const offset = $derived((call.input.offset as number | undefined) ?? 1);
// Result kann sehr lang sein — nur erste 40 Zeilen rendern, Rest als "+N weitere"
const MAX_LINES = 40;
const allLines = $derived((call.result || '').split('\n'));
const visibleLines = $derived(allLines.slice(0, MAX_LINES));
const hiddenCount = $derived(Math.max(0, allLines.length - MAX_LINES));
let copied = $state(false);
async function copyResult() {
try {
await navigator.clipboard.writeText(call.result || '');
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {
/* ignore */
}
}
</script>
<ToolCallCard {call}>
{#snippet children()}
{#if call.status === 'running'}
<div class="placeholder">… Datei wird gelesen …</div>
{:else if call.status === 'error'}
<div class="error-msg">{call.result || 'Fehler beim Lesen'}</div>
{:else if call.result}
<div class="actions">
<button class="action-btn" onclick={copyResult}>
{copied ? '✓ Kopiert' : '📋 Kopieren'}
</button>
</div>
<pre class="code-block"><code>{#each visibleLines as line, i}<span class="line"><span class="lineno">{offset + i}</span><span class="lineof"></span><span class="lineco">{line}</span>
</span>{/each}</code></pre>
{#if hiddenCount > 0}
<div class="more-hint">… +{hiddenCount} weitere Zeilen</div>
{/if}
{:else}
<div class="placeholder">{filePath}</div>
{/if}
{/snippet}
</ToolCallCard>
<style>
.actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-bottom: 4px;
}
.action-btn {
font-size: 11px;
padding: 2px 8px;
color: var(--vscode-descriptionForeground);
border-radius: 2px;
background: transparent;
}
.action-btn:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
.code-block {
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
background: var(--vscode-terminal-background);
color: var(--vscode-editor-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
overflow-x: auto;
max-height: 360px;
overflow-y: auto;
padding: 6px 0;
}
.code-block code { background: none; padding: 0; }
.line {
display: grid;
grid-template-columns: 44px 12px 1fr;
column-gap: 0;
}
.lineno {
color: var(--vscode-editorLineNumber-foreground);
text-align: right;
padding-right: 8px;
user-select: none;
}
.lineof {
color: var(--vscode-editorLineNumber-foreground);
opacity: 0.5;
user-select: none;
}
.lineco {
white-space: pre;
padding-left: 6px;
}
.placeholder {
color: var(--vscode-descriptionForeground);
font-size: 11.5px;
font-family: var(--font-mono);
}
.error-msg {
color: var(--vscode-errorForeground);
font-family: var(--font-mono);
font-size: 11.5px;
white-space: pre-wrap;
}
.more-hint {
margin-top: 4px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
</style>

View file

@ -0,0 +1,126 @@
<script lang="ts">
// Werkzeug-Drawer (Phase 9)
//
// Klick auf Sidebar-Nav oeffnet diesen Drawer von rechts. Pro Sektion
// werden mehrere Sub-Tabs gerendert (Sub-Tabs als <Tabs />-Bar oben).
//
// Sektionen:
// activity → ActivityPanel · MonitorPanel · PerformancePanel
// memory → MemoryPanel · KnowledgePanel · ContextPanel
// tools → ProgramsPanel · VoicePanel · AgentView · GuardRailsPanel · HooksPanel
// settings → SettingsPanel · AuditLog
import Drawer from '$lib/ui/Drawer.svelte';
import Tabs from '$lib/ui/Tabs.svelte';
import type { DrawerSection } from './Sidebar.svelte';
interface Props {
section: DrawerSection | null;
onClose: () => void;
}
let { section, onClose }: Props = $props();
// Sub-Tabs pro Sektion
const tabsBySection: Record<DrawerSection, { id: string; label: string }[]> = {
activity: [
{ id: 'activity', label: 'Live' },
{ id: 'monitor', label: 'Monitor' },
{ id: 'perf', label: 'Kosten' },
],
memory: [
{ id: 'memory', label: 'Gedächtnis' },
{ id: 'knowledge', label: 'Wissensbasis' },
{ id: 'context', label: 'Kontext' },
],
tools: [
{ id: 'programs', label: 'Programme' },
{ id: 'voice', label: 'Sprache' },
{ id: 'agents', label: 'Agenten' },
{ id: 'guards', label: 'Guard-Rails' },
{ id: 'hooks', label: 'Hooks' },
],
settings: [
{ id: 'settings', label: 'Einstellungen' },
{ id: 'audit', label: 'Audit-Log' },
],
};
const sectionTitles: Record<DrawerSection, string> = {
activity: 'Aktivität',
memory: 'Speicher',
tools: 'Werkzeuge',
settings: 'Einstellungen',
};
// Lazy-Load der Panels
const lazyPanels: Record<string, () => Promise<{ default: any }>> = {
activity: () => import('./ActivityPanel.svelte'),
monitor: () => import('./MonitorPanel.svelte'),
perf: () => import('./PerformancePanel.svelte'),
memory: () => import('./MemoryPanel.svelte'),
knowledge: () => import('./KnowledgePanel.svelte'),
context: () => import('./ContextPanel.svelte'),
programs: () => import('./ProgramsPanel.svelte'),
voice: () => import('./VoicePanel.svelte'),
agents: () => import('./AgentView.svelte'),
guards: () => import('./GuardRailsPanel.svelte'),
hooks: () => import('./HooksPanel.svelte'),
settings: () => import('./SettingsPanel.svelte'),
audit: () => import('./AuditLog.svelte'),
};
const cache: Record<string, Promise<{ default: any }>> = {};
function getPanel(id: string) {
if (!cache[id]) cache[id] = lazyPanels[id]();
return cache[id];
}
// Aktiver Sub-Tab pro Sektion (separat tracken, damit Wechsel der Sektion nicht reset)
let activeTabBySection = $state<Record<DrawerSection, string>>({
activity: 'activity',
memory: 'memory',
tools: 'programs',
settings: 'settings',
});
const activeTab = $derived(section ? activeTabBySection[section] : '');
const tabs = $derived(section ? tabsBySection[section] : []);
const title = $derived(section ? sectionTitles[section] : '');
function setActiveTab(id: string) {
if (!section) return;
activeTabBySection = { ...activeTabBySection, [section]: id };
}
</script>
<Drawer open={section !== null} {title} {onClose} width={420}>
{#if section}
<Tabs items={tabs} active={activeTab} onSelect={setActiveTab} />
<div class="content">
{#if activeTab}
{#await getPanel(activeTab)}
<div class="placeholder">Lade …</div>
{:then mod}
<svelte:component this={mod.default} />
{:catch err}
<div class="error">Fehler beim Laden: {err}</div>
{/await}
{/if}
</div>
{/if}
</Drawer>
<style>
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.placeholder, .error {
padding: var(--sp-4);
font-size: var(--fs-sm);
color: var(--text-secondary);
}
.error {
color: var(--status-error);
}
</style>

View file

@ -503,7 +503,7 @@
.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
color: var(--status-error);
padding: var(--spacing-sm);
border-radius: var(--radius-md);
font-size: 0.85rem;

View file

@ -630,12 +630,12 @@
.badge.ok {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
color: var(--status-success);
}
.badge.fail {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
color: var(--status-error);
}
/* Haupt-Button */
@ -665,7 +665,7 @@
}
.conversation-btn.active {
background: #ef4444;
background: var(--status-error);
}
.conversation-btn:disabled {
@ -691,17 +691,17 @@
.state-display.listening {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
color: var(--status-success);
}
.state-display.speaking {
background: rgba(96, 165, 250, 0.1);
color: #60a5fa;
color: var(--status-info);
}
.state-display.thinking {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
color: var(--status-warning);
}
.pulse-ring {
@ -824,7 +824,7 @@
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
color: #ef4444;
color: var(--status-error);
font-size: 0.8rem;
margin-top: var(--spacing-sm);
}

View file

@ -28,6 +28,17 @@ export interface ToolCall {
result?: unknown;
}
// Inline Tool-Call der einer Message angehaengt ist (fuer Inline-Karten im Chat)
export interface InlineToolCall {
id: string; // toolId aus dem Backend
tool: string; // Read, Edit, Write, Bash, Grep, Glob, WebFetch, Task, MCP, ...
input: Record<string, unknown>;
status: 'running' | 'done' | 'error';
result?: string; // Stringifizierte Tool-Ausgabe (optional, wird beim tool-end gesetzt)
startedAt: Date;
completedAt?: Date;
}
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
@ -36,6 +47,7 @@ export interface Message {
agentId?: string;
model?: string;
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
toolCalls?: InlineToolCall[]; // Inline gerenderte Tool-Karten (Phase 8)
}
export interface Permission {

View file

@ -32,11 +32,71 @@ import {
type KnowledgeHint,
type AgentMode,
type FileChange,
type InlineToolCall,
} from './app';
// Aktuell laufendes Tool (für inline Aktivitätsanzeige)
export const currentTool = writable<{ tool: string; input: Record<string, unknown> } | null>(null);
// --- Inline-Tool-Cards in Assistant-Message (Phase 8) ----------------------
// Haengt einen neuen Tool-Call an die letzte Assistant-Nachricht an.
// Wenn keine Assistant-Message existiert, wird eine Platzhalter-Nachricht
// erzeugt (Edge-Case: Tool feuert vor erstem Token).
function appendToolCallToLastAssistant(toolId: string, tool: string, input: Record<string, unknown>) {
const newCall: InlineToolCall = {
id: toolId,
tool: tool || 'unknown',
input: input || {},
status: 'running',
startedAt: new Date(),
};
messages.update((msgs) => {
// Letzte Assistant-Message finden (von hinten)
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant') {
const m = msgs[i];
const next = { ...m, toolCalls: [...(m.toolCalls || []), newCall] };
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
}
// Wenn wir auf eine User-Message stossen ohne Assistant dazwischen → neue Platzhalter-Assistant
if (msgs[i].role === 'user') break;
}
// Kein Assistant-Slot → minimaler Platzhalter
const placeholder: Message = {
id: `m-tool-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
toolCalls: [newCall],
};
return [...msgs, placeholder];
});
}
// Aktualisiert Status + Result des passenden Tool-Calls in den Messages.
function finalizeInlineToolCall(toolId: string, output: string | undefined, isError: boolean) {
messages.update((msgs) => {
// Suche von hinten nach der Message mit dem passenden toolId
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i];
if (!m.toolCalls?.length) continue;
const idx = m.toolCalls.findIndex((c) => c.id === toolId);
if (idx === -1) continue;
const updatedCalls = [...m.toolCalls];
updatedCalls[idx] = {
...updatedCalls[idx],
status: isError ? 'error' : 'done',
result: output,
completedAt: new Date(),
};
const next = { ...m, toolCalls: updatedCalls };
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
}
return msgs;
});
}
// Detaillierte Verarbeitungsphase für Status-Anzeige
export type ProcessingPhase = 'thinking' | 'streaming' | 'tool-use' | 'subagent' | 'idle';
export const processingPhase = writable<ProcessingPhase>('idle');
@ -244,6 +304,9 @@ export async function initEventListeners(): Promise<void> {
return ags;
});
// Phase 8: Inline-Karte an die letzte Assistant-Message haengen
appendToolCallToLastAssistant(id, tool || 'unknown', input || {});
// Hook: pre-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PreToolUse',
@ -287,6 +350,9 @@ export async function initEventListeners(): Promise<void> {
processingPhase.set('thinking');
completeToolCall(id, output, !success);
// Phase 8: Inline-Karte in der Assistant-Message finalisieren
finalizeInlineToolCall(id, output, !success);
// Hook: post-tool-use (fire-and-forget, Fehler blockieren nicht)
invoke('fire_hook', {
event: 'PostToolUse',

130
src/lib/theme/vscode.css Normal file
View file

@ -0,0 +1,130 @@
/* VS-Code-kompatible Variablen-Aliase (Phase 9)
*
* Phase 8 hatte hier eigene `--vscode-*` Werte die Single Source of
* Truth liegt jetzt in app.css (Phase 9). Diese Datei mappt die
* `--vscode-*` Namen weiterhin auf die neuen Variablen, damit Phase-8-
* Komponenten (DiffView, ToolCallCard, Message, ToolCardRead/Edit/...)
* unveraendert weiterlaufen.
*/
:root {
/* Backgrounds */
--vscode-editor-background: var(--bg-primary);
--vscode-editor-foreground: var(--text-primary);
--vscode-sideBar-background: var(--bg-secondary);
--vscode-sideBarSectionHeader-background:var(--bg-tertiary);
--vscode-titleBar-activeBackground: var(--bg-secondary);
--vscode-titleBar-activeForeground: var(--text-primary);
/* Inputs */
--vscode-input-background: var(--bg-input);
--vscode-input-foreground: var(--text-primary);
--vscode-input-border: var(--border);
/* Buttons */
--vscode-button-background: var(--accent);
--vscode-button-foreground: var(--accent-fg);
--vscode-button-hoverBackground: var(--accent-hover);
--vscode-button-secondaryBackground: var(--bg-tertiary);
--vscode-button-secondaryHoverBackground: var(--bg-hover);
/* Text */
--vscode-foreground: var(--text-primary);
--vscode-descriptionForeground: var(--text-secondary);
--vscode-disabledForeground: var(--text-disabled);
--vscode-focusBorder: var(--focus);
/* Status */
--vscode-errorForeground: var(--status-error);
--vscode-warningForeground: var(--status-warning);
--vscode-successForeground: var(--status-success);
/* Badges */
--vscode-badge-background: var(--bg-tertiary);
--vscode-badge-foreground: var(--text-primary);
--vscode-progressBar-background: var(--accent);
--vscode-widget-shadow: rgba(0, 0, 0, 0.4);
/* Diff */
--vscode-diffEditor-insertedTextBackground: var(--diff-added-bg);
--vscode-diffEditor-removedTextBackground: var(--diff-removed-bg);
--vscode-diffEditor-insertedLineBackground: rgba(106, 153, 85, 0.10);
--vscode-diffEditor-removedLineBackground: rgba(244, 135, 113, 0.10);
/* Editor highlights */
--vscode-editor-lineHighlightBackground: var(--bg-hover);
--vscode-editorLineNumber-foreground: var(--text-disabled);
--vscode-editorLineNumber-activeForeground: var(--text-secondary);
--vscode-editorIndentGuide-background: var(--border);
/* Terminal */
--vscode-terminal-background: var(--bg-primary);
--vscode-terminal-foreground: var(--text-primary);
/* Listen */
--vscode-list-hoverBackground: var(--bg-hover);
--vscode-list-activeSelectionBackground: var(--bg-selected);
--vscode-list-inactiveSelectionBackground: var(--bg-tertiary);
/* Scrollbar */
--vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4);
--vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
--vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
}
/* Utility-Klassen aus Phase 8 — bleiben kompatibel */
.vscode-card {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.vscode-card-accent {
border-left: 3px solid var(--accent);
}
.vscode-card-accent.running {
border-left-color: var(--accent);
animation: pulse 1.6s ease-in-out infinite;
}
.vscode-card-accent.error {
border-left-color: var(--status-error);
}
.vscode-card-accent.success {
border-left-color: var(--status-success);
}
.vscode-mono {
font-family: var(--font-mono);
font-size: var(--fs-md);
line-height: var(--lh-code);
}
.vscode-hint {
color: var(--text-secondary);
font-size: var(--fs-xs);
}
.vscode-badge {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 1px var(--sp-2);
border-radius: 2px;
font-size: var(--fs-xs);
line-height: var(--lh-tight);
}
.vscode-badge.clickable {
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.vscode-badge.clickable:hover {
background: var(--bg-hover);
}

60
src/lib/ui/Badge.svelte Normal file
View file

@ -0,0 +1,60 @@
<script lang="ts">
// Status-Badge mit konsistenten Farben (kein Hardcoding mehr).
// Nutzung:
// <Badge>Standard</Badge>
// <Badge tone="success">Aktiv</Badge>
// <Badge tone="warning">Achtung</Badge>
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'success' | 'warning' | 'error' | 'info' | 'accent';
title?: string;
children?: Snippet;
}
let { tone = 'neutral', title, children }: Props = $props();
</script>
<span class="badge" data-tone={tone} {title}>
{@render children?.()}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
line-height: var(--lh-tight);
padding: 1px var(--sp-2);
border-radius: 2px;
border: 1px solid transparent;
white-space: nowrap;
}
.badge[data-tone="neutral"] {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.badge[data-tone="success"] {
background: rgba(106, 153, 85, 0.18);
color: var(--status-success);
}
.badge[data-tone="warning"] {
background: rgba(245, 166, 35, 0.18);
color: var(--status-warning);
}
.badge[data-tone="error"] {
background: rgba(244, 135, 113, 0.18);
color: var(--status-error);
}
.badge[data-tone="info"] {
background: rgba(86, 156, 214, 0.18);
color: var(--status-info);
}
.badge[data-tone="accent"] {
background: rgba(0, 122, 204, 0.18);
color: var(--accent);
}
</style>

119
src/lib/ui/Button.svelte Normal file
View file

@ -0,0 +1,119 @@
<script lang="ts">
// Konsistente Button-Komponente mit 3 Varianten und 2 Groessen.
// Nutzung:
// <Button onclick={...}>Speichern</Button>
// <Button variant="ghost" size="sm">Abbrechen</Button>
// <Button variant="primary" disabled>Senden</Button>
import type { Snippet } from 'svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
disabled?: boolean;
title?: string;
type?: 'button' | 'submit';
onclick?: (e: MouseEvent) => void;
children?: Snippet;
}
let {
variant = 'secondary',
size = 'md',
disabled = false,
title,
type = 'button',
onclick,
children,
}: Props = $props();
</script>
<button
{type}
{title}
{disabled}
class="btn"
data-variant={variant}
data-size={size}
{onclick}
>
{@render children?.()}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
font-family: var(--font-sans);
font-weight: var(--fw-medium);
border: 1px solid transparent;
border-radius: var(--r-sm);
cursor: pointer;
transition: background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease);
white-space: nowrap;
}
/* Groessen */
.btn[data-size="sm"] {
font-size: var(--fs-sm);
padding: var(--sp-1) var(--sp-2);
min-height: 24px;
}
.btn[data-size="md"] {
font-size: var(--fs-md);
padding: var(--sp-2) var(--sp-3);
min-height: 30px;
}
/* Varianten */
.btn[data-variant="primary"] {
background: var(--accent);
color: var(--accent-fg);
}
.btn[data-variant="primary"]:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn[data-variant="secondary"] {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border);
}
.btn[data-variant="secondary"]:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.btn[data-variant="ghost"] {
background: transparent;
color: var(--text-secondary);
}
.btn[data-variant="ghost"]:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn[data-variant="danger"] {
background: transparent;
color: var(--status-error);
border-color: var(--status-error);
}
.btn[data-variant="danger"]:hover:not(:disabled) {
background: var(--status-error);
color: var(--bg-primary);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:focus-visible {
outline: 1px solid var(--focus);
outline-offset: 1px;
}
</style>

47
src/lib/ui/Card.svelte Normal file
View file

@ -0,0 +1,47 @@
<script lang="ts">
// Karten-Container mit konsistenter Border + Background.
// Wird in jedem Panel als wiederkehrende Klammer verwendet.
// Nutzung:
// <Card>...</Card>
// <Card padding="sm" interactive>...</Card>
import type { Snippet } from 'svelte';
interface Props {
padding?: 'none' | 'sm' | 'md' | 'lg';
interactive?: boolean;
accent?: boolean; // 3px Akzent-Border links
children?: Snippet;
}
let { padding = 'md', interactive = false, accent = false, children }: Props = $props();
</script>
<div class="card" class:interactive class:accent data-padding={padding}>
{@render children?.()}
</div>
<style>
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.card[data-padding="none"] { padding: 0; }
.card[data-padding="sm"] { padding: var(--sp-2); }
.card[data-padding="md"] { padding: var(--sp-3); }
.card[data-padding="lg"] { padding: var(--sp-4); }
.card.interactive {
cursor: pointer;
transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.card.interactive:hover {
background: var(--bg-tertiary);
border-color: var(--border-strong);
}
.card.accent {
border-left: 3px solid var(--accent);
}
</style>

111
src/lib/ui/Drawer.svelte Normal file
View file

@ -0,0 +1,111 @@
<script lang="ts">
// Rechts-eingeschobenes Panel fuer Werkzeug-Tabs.
// Esc schliesst, Klick auf Backdrop schliesst.
//
// Nutzung:
// <Drawer open={openDrawer === 'memory'} onClose={() => openDrawer = null}>
// ... Inhalt ...
// </Drawer>
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
interface Props {
open: boolean;
title?: string;
width?: number;
onClose?: () => void;
children?: Snippet;
}
let { open, title, width = 360, onClose, children }: Props = $props();
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
e.preventDefault();
onClose?.();
}
}
onMount(() => {
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
});
</script>
{#if open}
<div
class="backdrop"
role="presentation"
onclick={() => onClose?.()}
onkeydown={() => {}}
aria-hidden="true"
></div>
<aside class="drawer" style="width: {width}px" role="dialog" aria-label={title ?? 'Drawer'}>
{#if title}
<header class="head">
<span class="title">{title}</span>
<button class="close-btn" onclick={() => onClose?.()} title="Schliessen (Esc)">×</button>
</header>
{/if}
<div class="body">
{@render children?.()}
</div>
</aside>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 80;
animation: fade-in var(--dur-fast) var(--ease);
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
z-index: 81;
animation: slide-in-right var(--dur-base) var(--ease);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-2) var(--sp-3);
min-height: var(--titlebar-height);
border-bottom: 1px solid var(--border);
}
.title {
font-size: var(--fs-lg);
font-weight: var(--fw-semi);
color: var(--text-heading);
}
.close-btn {
font-size: 18px;
line-height: 1;
padding: 0 var(--sp-2);
color: var(--text-secondary);
border-radius: var(--r-sm);
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>

26
src/lib/ui/Icon.svelte Normal file
View file

@ -0,0 +1,26 @@
<script lang="ts">
// Wrapper um lucide-svelte mit fester Groesse + currentColor.
// Nutzung:
// <Icon icon={FileText} size={16} />
// <Icon icon={Check} size="lg" />
import type { Component } from 'svelte';
interface Props {
icon: Component<{ size?: number | string; color?: string; strokeWidth?: number; class?: string }>;
size?: number | 'sm' | 'md' | 'lg';
strokeWidth?: number;
class?: string;
}
let { icon: IconComp, size = 16, strokeWidth = 2, class: className = '' }: Props = $props();
const px = $derived.by(() => {
if (typeof size === 'number') return size;
if (size === 'sm') return 14;
if (size === 'lg') return 20;
return 16;
});
</script>
<IconComp size={px} strokeWidth={strokeWidth} class={className} />

View file

@ -0,0 +1,45 @@
<script lang="ts">
// CSS-Status-Dot — ersetzt die Emoji-Status (🟢 🟡 🔴).
// Nutzung:
// <StatusDot status="success" />
// <StatusDot status="warning" pulse />
type Status = 'success' | 'warning' | 'error' | 'info' | 'idle';
interface Props {
status?: Status;
pulse?: boolean;
size?: number;
}
let { status = 'idle', pulse = false, size = 8 }: Props = $props();
</script>
<span
class="dot"
class:pulse
data-status={status}
style="width: {size}px; height: {size}px"
aria-hidden="true"
></span>
<style>
.dot {
display: inline-block;
border-radius: 50%;
flex-shrink: 0;
}
.dot[data-status="success"] { background: var(--status-success); }
.dot[data-status="warning"] { background: var(--status-warning); }
.dot[data-status="error"] { background: var(--status-error); }
.dot[data-status="info"] { background: var(--status-info); }
.dot[data-status="idle"] { background: var(--text-disabled); }
.dot.pulse {
animation: dotpulse 1.6s ease-in-out infinite;
}
@keyframes dotpulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
</style>

94
src/lib/ui/Tabs.svelte Normal file
View file

@ -0,0 +1,94 @@
<script lang="ts">
// Konsistente Tab-Bar.
// Nutzung:
// <Tabs items={[{ id: 'a', label: 'A' }, ...]} bind:active />
//
// Es werden nur Tab-Header gerendert; den aktiven Inhalt rendert der Caller.
interface TabItem {
id: string;
label: string;
count?: number;
}
interface Props {
items: TabItem[];
active: string;
onSelect?: (id: string) => void;
size?: 'sm' | 'md';
}
let { items, active = $bindable(), onSelect, size = 'md' }: Props = $props();
function pick(id: string) {
active = id;
onSelect?.(id);
}
</script>
<div class="tabs" data-size={size} role="tablist">
{#each items as item (item.id)}
<button
class="tab"
class:active={item.id === active}
role="tab"
aria-selected={item.id === active}
onclick={() => pick(item.id)}
>
<span>{item.label}</span>
{#if typeof item.count === 'number'}
<span class="count">{item.count}</span>
{/if}
</button>
{/each}
</div>
<style>
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
color: var(--text-secondary);
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tabs[data-size="sm"] .tab {
font-size: var(--fs-xs);
padding: var(--sp-1) var(--sp-2);
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.count {
font-size: 10px;
font-variant-numeric: tabular-nums;
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 0 5px;
border-radius: 8px;
line-height: 14px;
}
.tab.active .count {
background: var(--accent);
color: var(--accent-fg);
}
</style>

74
src/lib/ui/Tooltip.svelte Normal file
View file

@ -0,0 +1,74 @@
<script lang="ts">
// Einfacher Hover-Tooltip via CSS — ohne Portal, ohne Floating-Lib.
// Wenn aufwendigere Anker-Logik gebraucht wird, wechseln auf @floating-ui/dom.
//
// Nutzung:
// <Tooltip text="Bearbeiten">
// <button onclick={...}><Icon icon={Pencil} /></button>
// </Tooltip>
import type { Snippet } from 'svelte';
interface Props {
text: string;
placement?: 'top' | 'bottom' | 'left' | 'right';
children?: Snippet;
}
let { text, placement = 'top', children }: Props = $props();
</script>
<span class="tt-wrap" data-placement={placement}>
{@render children?.()}
<span class="tt" role="tooltip">{text}</span>
</span>
<style>
.tt-wrap {
position: relative;
display: inline-flex;
}
.tt {
position: absolute;
z-index: 100;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 2px var(--sp-2);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
box-shadow: var(--shadow-sm);
}
.tt-wrap:hover .tt,
.tt-wrap:focus-within .tt {
opacity: 1;
transition-delay: 400ms;
}
.tt-wrap[data-placement="top"] .tt {
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.tt-wrap[data-placement="bottom"] .tt {
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.tt-wrap[data-placement="left"] .tt {
right: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
}
.tt-wrap[data-placement="right"] .tt {
left: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
}
</style>

13
src/lib/ui/index.ts Normal file
View file

@ -0,0 +1,13 @@
// Re-export der UI-Library-Bausteine
//
// Damit Komponenten kompakt importieren koennen:
// import { Button, Card, Icon, StatusDot } from '$lib/ui';
export { default as Button } from './Button.svelte';
export { default as Card } from './Card.svelte';
export { default as Icon } from './Icon.svelte';
export { default as Badge } from './Badge.svelte';
export { default as StatusDot } from './StatusDot.svelte';
export { default as Tooltip } from './Tooltip.svelte';
export { default as Drawer } from './Drawer.svelte';
export { default as Tabs } from './Tabs.svelte';

56
src/lib/utils/markdown.ts Normal file
View file

@ -0,0 +1,56 @@
// Markdown-Renderer fuer Chat-Messages.
// Kompakter Wrapper um marked + Custom Code-Block-Renderer.
// Wird aus Message.svelte (Phase 8) und ChatPanel (legacy) verwendet.
import { marked, type Tokens } from 'marked';
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: Tokens.Code): string {
const language = lang || '';
const escapedCode = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<div class="code-block-wrapper" data-lang="${language}"><pre><code class="language-${language}">${escapedCode}</code></pre></div>`;
};
marked.setOptions({ breaks: true, gfm: true, renderer });
/**
* Erkennt "Denk-Bloecke" im Text und zeigt sie als kompakte Inline-Elemente.
* Zwei Quellen:
* 1. SDK Extended-Thinking (kommt als <div class="thinking-inline"> aus der Bridge)
* 2. Text-basierte Patterns (Lass mich analysieren..., Ich schaue mir...)
*/
function collapseThinkingBlocks(text: string): string {
if (text.includes('<div class="thinking-inline">')) return text;
if (text.includes('<details class="thinking-block">')) {
return text.replace(
/<details class="thinking-block">.*?<div class="thinking-content">([\s\S]*?)<\/div><\/details>/g,
(_m, content) => `<div class="thinking-inline"><span class="thinking-label">\u{1F4AD}</span><span class="thinking-text">${content}</span></div>`
);
}
const thinkingPatterns = [
/^((?:(?:Lass mich|Let me|Ich (?:schaue|prüfe|analysiere|untersuche|überleg|werde)|OK,? (?:lass|ich)|Gut,? (?:lass|ich)|Hmm|Also,? |Zunächst|Zuerst|Schauen wir|Jetzt (?:schaue|prüfe|check)).*?\n(?:.*\n)*?))((?:\n(?:#{1,3} |(?:\*\*|Die |Das |Hier |Zusammen|Fertig|Erledigt|Done|✅|---)).*[\s\S]*))/m,
];
for (const pattern of thinkingPatterns) {
const match = text.match(pattern);
if (match && match[1] && match[1].split('\n').length > 5) {
const thinkingPart = match[1].trim().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const answerPart = match[2].trim();
return `<div class="thinking-inline"><span class="thinking-label">\u{1F4AD}</span><span class="thinking-text">${thinkingPart}</span></div>\n\n${answerPart}`;
}
}
return text;
}
export function renderMarkdown(text: string): string {
try {
return marked.parse(collapseThinkingBlocks(text)) as string;
} catch {
return text;
}
}

122
src/lib/utils/toolCards.ts Normal file
View file

@ -0,0 +1,122 @@
// Helper fuer Inline-Tool-Karten in Assistant-Messages (Phase 8)
//
// Mapping Tool-Name -> Icon, Akzentfarbe, Default-Collapse-Verhalten,
// Subtitel-Generierung. Die eigentliche Card-Komponente wird in
// ToolCallCard.svelte gewaehlt; hier sind nur statische Metadaten.
export type ToolKind =
| 'read' // Read, Glob, NotebookRead
| 'edit' // Edit, Write, NotebookEdit, MultiEdit
| 'bash' // Bash, BashOutput, KillShell
| 'search' // Grep, Glob (alternativ)
| 'web' // WebFetch, WebSearch
| 'task' // Task (Subagent)
| 'mcp' // mcp__*
| 'todo' // TodoWrite
| 'generic';
export interface ToolMeta {
kind: ToolKind;
icon: string; // Emoji-Icon (matcht VS-Code-Extension-Konvention)
label: string; // Lesbarer Tool-Name (Original beibehalten wenn moeglich)
collapseWhenDone: boolean; // VS-Code: Read collapsed nach Erfolg, Edit aufgeklappt
}
export function getToolMeta(toolName: string): ToolMeta {
const t = (toolName || '').toLowerCase();
// MCP-Tools (mcp__server__name)
if (t.startsWith('mcp__')) {
return { kind: 'mcp', icon: '🛠️', label: toolName, collapseWhenDone: true };
}
// Edit/Write-Familie
if (t === 'edit' || t === 'write' || t === 'multiedit' || t === 'notebookedit') {
return { kind: 'edit', icon: '✏️', label: toolName, collapseWhenDone: false };
}
// Read-Familie
if (t === 'read' || t === 'notebookread') {
return { kind: 'read', icon: '📖', label: toolName, collapseWhenDone: true };
}
// Bash-Familie
if (t === 'bash' || t === 'bashoutput' || t === 'killshell') {
return { kind: 'bash', icon: '🖥️', label: toolName, collapseWhenDone: true };
}
// Such-Tools
if (t === 'grep') return { kind: 'search', icon: '🔍', label: toolName, collapseWhenDone: true };
if (t === 'glob') return { kind: 'search', icon: '📁', label: toolName, collapseWhenDone: true };
// Web-Tools
if (t === 'webfetch') return { kind: 'web', icon: '🌐', label: toolName, collapseWhenDone: true };
if (t === 'websearch') return { kind: 'web', icon: '🔎', label: toolName, collapseWhenDone: true };
// Subagent-Spawn
if (t === 'task') return { kind: 'task', icon: '👥', label: toolName, collapseWhenDone: false };
// Todo
if (t === 'todowrite') return { kind: 'todo', icon: '📋', label: toolName, collapseWhenDone: true };
// Fallback
return { kind: 'generic', icon: '⚙️', label: toolName || 'Tool', collapseWhenDone: true };
}
// Erzeugt einen kompakten Untertitel fuer den Karten-Header.
// Bsp: { file_path: "src/app.ts", offset: 45, limit: 35 } -> "src/app.ts:45-79"
export function getToolSubtitle(tool: string, input: Record<string, unknown> = {}): string {
const t = (tool || '').toLowerCase();
const path = (input.file_path || input.path || input.notebook_path) as string | undefined;
if (path) {
// Pfad kuerzen: "/mnt/17 - Entwicklungen/.../foo/bar.ts" -> "bar.ts"
const short = path.split('/').slice(-2).join('/');
if (t === 'read' || t === 'notebookread') {
const offset = input.offset as number | undefined;
const limit = input.limit as number | undefined;
if (typeof offset === 'number' && typeof limit === 'number') {
return `${short}:${offset}-${offset + limit - 1}`;
}
return short;
}
return short;
}
if (t === 'bash' || t === 'bashoutput') {
const cmd = (input.command as string) || (input.bash_id as string) || '';
return cmd.length > 60 ? cmd.slice(0, 57) + '…' : cmd;
}
if (t === 'grep') {
const pattern = (input.pattern as string) || '';
const where = (input.path as string) || (input.glob as string) || '';
return where ? `"${pattern}" in ${where}` : `"${pattern}"`;
}
if (t === 'glob') {
return (input.pattern as string) || '';
}
if (t === 'webfetch') return (input.url as string) || '';
if (t === 'websearch') return (input.query as string) || '';
if (t === 'task') {
const desc = (input.description as string) || (input.subagent_type as string) || '';
return desc;
}
if (t.startsWith('mcp__')) {
// Tool-Name selbst ist informativ genug
return '';
}
if (t === 'todowrite') {
const todos = input.todos as Array<{ status?: string }> | undefined;
if (Array.isArray(todos)) {
const total = todos.length;
const done = todos.filter(x => x.status === 'completed').length;
return `${done}/${total} erledigt`;
}
return '';
}
return '';
}

View file

@ -1,10 +1,15 @@
<script lang="ts">
import '../app.css';
import '$lib/theme/vscode.css';
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, queuedMessage, messageQueue, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores';
import StopButton from '$lib/components/StopButton.svelte';
import UpdateDialog from '$lib/components/UpdateDialog.svelte';
import StatusBar from '$lib/components/StatusBar.svelte';
import { GraduationCap } from 'lucide-svelte';
import Icon from '$lib/ui/Icon.svelte';
import Tooltip from '$lib/ui/Tooltip.svelte';
// Session-Typ vom Backend
interface Session {
@ -151,34 +156,24 @@
<svelte:window on:keydown={handleKeydown} />
<div class="app-container">
<!-- Titelleiste -->
<!-- Titelleiste (Phase 9: minimal — nur Logo, Stop, Schulungsmodus, Version) -->
<header class="titlebar">
<div class="titlebar-left">
<h1>Claude Desktop</h1>
</div>
<div class="titlebar-center">
{#if $isProcessing}
<span class="status-dot active"></span>
<span>Arbeitet...</span>
{:else}
<span class="status-dot idle"></span>
<span>Bereit</span>
{/if}
<span class="brand-mark"></span>
<span class="brand">Claude Desktop</span>
</div>
<div class="titlebar-right">
{#if $isProcessing}
<StopButton on:click={handleStop} />
{/if}
<button
class="teach-btn"
title="Schulungsmodus (Präsentations-Fenster)"
onclick={() => invoke('presentation_open').catch(e => console.error(e))}
>
🎓
</button>
{#if $currentModel}
<span class="model-badge">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
{/if}
<Tooltip text="Schulungsmodus">
<button
class="icon-btn"
onclick={() => invoke('presentation_open').catch(e => console.error(e))}
>
<Icon icon={GraduationCap} size={16} />
</button>
</Tooltip>
{#if appVersion}
<span class="version-badge" class:dev={appVersion === 'dev'} title="App-Version">
v{appVersion}
@ -191,41 +186,8 @@
<slot />
</main>
<!-- Footer: nur Stats (Stop-Button ist in die Titlebar gewandert) -->
<footer class="footer">
<div class="footer-stats">
{#if $stickyContextInfo?.loaded}
<span class="context-badge" title="Sticky Context: {$stickyContextInfo.entries} Einträge, ~{$stickyContextInfo.estimatedTokens} Token">
📌 +{$stickyContextInfo.estimatedTokens}ctx
</span>
<span class="sep">|</span>
{/if}
{#if $contextUsage.inputTokens > 0}
<span class="context-percent" class:warning={$contextPercent > 60} class:danger={$contextPercent > 80} title="{formatTokens($contextUsage.inputTokens)} von {formatTokens($contextUsage.contextLimit)} Token">
{$contextPercent}% ctx
</span>
<span class="sep">|</span>
{/if}
<span>Token: {formatTokens($sessionStats.totalTokensIn)} in / {formatTokens($sessionStats.totalTokensOut)} out</span>
<span class="sep">|</span>
<span>Kosten: {formatCost($sessionStats.totalCost)}</span>
<span class="sep">|</span>
<span>{$sessionStats.messageCount} Antworten</span>
{#if $currentModel}
<span class="sep">|</span>
<span class="model">{$currentModel.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}</span>
{/if}
{#if $agentMode && $agentMode !== 'solo'}
<span class="sep">|</span>
<span class="mode-badge mode-{$agentMode}" title="Agent-Modus: {$agentMode}">
{#if $agentMode === 'handlanger'}👷 Handlanger
{:else if $agentMode === 'experten'}🎓 Experten
{:else if $agentMode === 'auto'}🤖 Auto
{/if}
</span>
{/if}
</div>
</footer>
<!-- Status-Bar (Phase 9: ersetzt den alten Footer) -->
<StatusBar />
</div>
<!-- Auto-Update Dialog -->
@ -243,161 +205,69 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-md);
padding: 0 var(--sp-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
user-select: none;
height: 36px;
height: var(--titlebar-height);
flex-shrink: 0;
}
.titlebar h1 {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-heading);
}
.titlebar-center {
.titlebar-left {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.75rem;
color: var(--text-secondary);
gap: var(--sp-2);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status-dot.active {
background: var(--success);
animation: pulse 1.5s ease-in-out infinite;
}
.status-dot.idle {
background: var(--text-secondary);
}
.model-badge {
padding: 1px 6px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 0.65rem;
.brand-mark {
color: var(--accent);
font-weight: 600;
font-size: 14px;
font-weight: var(--fw-semi);
}
.version-badge {
font-size: 0.6rem;
color: var(--text-secondary);
font-family: var(--font-mono);
opacity: 0.7;
}
.version-badge.dev {
opacity: 0.4;
font-style: italic;
.brand {
font-size: var(--fs-md);
font-weight: var(--fw-semi);
color: var(--text-primary);
}
.titlebar-right {
display: flex;
gap: var(--spacing-xs);
font-size: 0.65rem;
align-items: center;
gap: var(--sp-2);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--r-sm);
color: var(--text-secondary);
background: transparent;
border: 0;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.version-badge {
font-size: var(--fs-xs);
color: var(--text-disabled);
font-family: var(--font-mono);
}
.sep {
opacity: 0.3;
.version-badge.dev {
font-style: italic;
}
.main-content {
flex: 1;
overflow: hidden;
}
.footer {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xs) var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.footer-stats {
display: flex;
gap: var(--spacing-sm);
font-size: 0.65rem;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.footer-stats .sep {
opacity: 0.3;
}
.footer-stats .model {
color: var(--accent);
font-weight: 600;
}
.footer-stats .context-badge {
color: #22c55e;
font-weight: 500;
cursor: help;
}
.footer-stats .context-percent {
color: #22c55e;
font-weight: 600;
cursor: help;
}
.footer-stats .context-percent.warning {
color: #eab308;
}
.footer-stats .context-percent.danger {
color: #ef4444;
animation: pulse 1.5s ease-in-out infinite;
}
.footer-stats .mode-badge {
font-weight: 600;
cursor: help;
padding: 1px 6px;
border-radius: 3px;
}
.footer-stats .mode-handlanger {
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
}
.footer-stats .mode-experten {
color: #a855f7;
background: rgba(168, 85, 247, 0.12);
}
.footer-stats .mode-auto {
color: #06b6d4;
background: rgba(6, 182, 212, 0.12);
}
.teach-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-primary);
font-size: 0.9rem;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
margin-right: var(--spacing-xs);
}
.teach-btn:hover {
background: rgba(96, 165, 250, 0.15);
min-height: 0;
}
</style>

View file

@ -1,285 +1,142 @@
<script lang="ts">
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
// Hauptfenster-Layout (Phase 9, Cursor-Stil 2-spaltig)
//
// ┌──────────────────────────────────────────┐
// │ Sidebar (240px) │ Hauptbereich (flex) │
// │ - Suche │ ChatPanel │
// │ - Sessions │ (oder Detached │
// │ - Nav-Rail │ Placeholder) │
// └──────────────────────────────────────────┘
// ToolDrawer wird ueber Sidebar-Nav getriggert und ueberlagert von rechts.
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { onMount } from 'svelte';
import { chatDetached } from '$lib/stores/app';
// Kritische Panels sofort laden (immer sichtbar)
import SessionList from '$lib/components/SessionList.svelte';
import ChatPanel from '$lib/components/ChatPanel.svelte';
import Sidebar, { type DrawerSection } from '$lib/components/Sidebar.svelte';
import ToolDrawer from '$lib/components/ToolDrawer.svelte';
import QuickActions from '$lib/components/QuickActions.svelte';
// Chat-Detach + Quick-Actions Navigation: Listener für Fenster-Events
onMount(async () => {
// Wenn das Chat-Fenster geschlossen wird → Chat wieder einblenden
await listen('chat-reattached', () => {
$chatDetached = false;
});
// Wenn das Chat-Fenster geöffnet wird → Chat ausblenden
await listen('chat-detached', () => {
$chatDetached = true;
});
// Quick-Actions Navigation: Tab in Panel aktivieren
await listen<{ panel: string; tab: string }>('navigate-tab', (event) => {
if (event.payload.panel === 'middle') {
activeMiddleTab = event.payload.tab;
} else if (event.payload.panel === 'right') {
activeRightTab = event.payload.tab;
}
});
});
let activeDrawer = $state<DrawerSection | null>(null);
let showQuickActions = $state(false);
// Sekundäre Panels: Lazy-Load bei erstem Tab-Wechsel
const lazyPanels = {
activity: () => import('$lib/components/ActivityPanel.svelte'),
monitor: () => import('$lib/components/MonitorPanel.svelte'),
perf: () => import('$lib/components/PerformancePanel.svelte'),
knowledge: () => import('$lib/components/KnowledgePanel.svelte'),
memory: () => import('$lib/components/MemoryPanel.svelte'),
audit: () => import('$lib/components/AuditLog.svelte'),
programs: () => import('$lib/components/ProgramsPanel.svelte'),
agents: () => import('$lib/components/AgentView.svelte'),
voice: () => import('$lib/components/VoicePanel.svelte'),
context: () => import('$lib/components/ContextPanel.svelte'),
hooks: () => import('$lib/components/HooksPanel.svelte'),
guards: () => import('$lib/components/GuardRailsPanel.svelte'),
settings: () => import('$lib/components/SettingsPanel.svelte'),
} as Record<string, () => Promise<{ default: any }>>;
// Cache für bereits geladene Module (kein Re-Import bei Tab-Wechsel)
const loadedPanels: Record<string, Promise<{ default: any }>> = {};
function getPanel(id: string): Promise<{ default: any }> {
if (!loadedPanels[id]) {
loadedPanels[id] = lazyPanels[id]();
}
return loadedPanels[id];
function toggleDrawer(section: DrawerSection) {
activeDrawer = activeDrawer === section ? null : section;
}
let activeMiddleTab = 'activity';
let activeRightTab = 'agents';
function openSearch() {
showQuickActions = true;
}
// Sofort die Default-Tabs vorladen (nach dem kritischen Pfad)
$: void getPanel(activeMiddleTab);
$: void getPanel(activeRightTab);
onMount(async () => {
// Detach/Reattach
await listen('chat-reattached', () => { $chatDetached = false; });
await listen('chat-detached', () => { $chatDetached = true; });
const middleTabs = [
{ id: 'activity', label: 'Aktivität', icon: '📋' },
{ id: 'monitor', label: 'Monitor', icon: '📊' },
{ id: 'perf', label: 'Kosten', icon: '📈' },
{ id: 'knowledge', label: 'Wissen', icon: '📚' },
{ id: 'memory', label: 'Gedächtnis', icon: '🧠' },
{ id: 'audit', label: 'Historie', icon: '📝' },
{ id: 'programs', label: 'Programme', icon: '🖥️' },
];
// Globaler Cmd/Ctrl+K Listener fuer Quick-Actions
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showQuickActions = !showQuickActions;
}
// Esc schliesst Drawer
if (e.key === 'Escape' && activeDrawer) {
activeDrawer = null;
}
};
window.addEventListener('keydown', handler);
const rightTabs = [
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'voice', label: 'Sprache', icon: '🎤' },
{ id: 'context', label: 'Context', icon: '📌' },
{ id: 'hooks', label: 'Hooks', icon: '🪝' },
{ id: 'guards', label: 'Guard-Rails', icon: '🛡️' },
{ id: 'settings', label: 'Settings', icon: '⚙️' },
];
// Quick-Actions Navigation: Drawer-Sektion bei Sub-Tab-Wahl oeffnen
await listen<{ panel: string; tab: string }>('navigate-tab', (event) => {
const t = event.payload.tab;
// Mappe Sub-Tab → Section
if (['activity', 'monitor', 'perf'].includes(t)) activeDrawer = 'activity';
else if (['memory', 'knowledge', 'context'].includes(t)) activeDrawer = 'memory';
else if (['programs', 'voice', 'agents', 'guards', 'hooks'].includes(t)) activeDrawer = 'tools';
else if (['settings', 'audit'].includes(t)) activeDrawer = 'settings';
});
return () => window.removeEventListener('keydown', handler);
});
function handleQuickAction(action: any) {
showQuickActions = false;
// Wenn die Action eine navigate-tab Anweisung war, oeffnet der Listener den Drawer
// Andere Actions werden vom ChatPanel verarbeitet (Slash-Commands etc.)
if (action?.invoke) {
invoke(action.invoke, action.invokeArgs ?? {}).catch((e) =>
console.error('Quick-Action invoke failed:', e)
);
}
}
</script>
{#key $chatDetached}
<PaneGroup direction="horizontal" autoSaveId="claude-desktop-panels" class="pane-group">
<!-- Sessions -->
<Pane defaultSize={15} minSize={8} maxSize={30} class="panel">
<SessionList />
</Pane>
<div class="layout">
<Sidebar
{activeDrawer}
onSearchOpen={openSearch}
onDrawerToggle={toggleDrawer}
/>
<PaneResizer class="resizer">
<div class="resizer-line"></div>
</PaneResizer>
<!-- Chat -->
{#if !$chatDetached}
<Pane defaultSize={35} minSize={15} class="panel">
<div class="main">
{#if !$chatDetached}
<ChatPanel />
</Pane>
{:else}
<Pane defaultSize={12} minSize={5} maxSize={20} class="panel">
<div class="detached-placeholder">
<span class="detached-icon">💬</span>
<p>Chat ist herausgelöst</p>
<button class="reattach-btn" onclick={() => invoke('chat_window_close')}>
{:else}
<div class="detached">
<p>Chat ist in einem eigenen Fenster.</p>
<button class="reattach" onclick={() => invoke('chat_window_close')}>
Zurückholen
</button>
</div>
</Pane>
{/if}
{/if}
</div>
</div>
<PaneResizer class="resizer">
<div class="resizer-line"></div>
</PaneResizer>
<ToolDrawer
section={activeDrawer}
onClose={() => (activeDrawer = null)}
/>
<!-- Aktivität / Memory / Audit -->
<Pane defaultSize={25} minSize={10} class="panel">
<div class="panel-tabs">
{#each middleTabs as tab}
<button
class="tab"
class:active={activeMiddleTab === tab.id}
onclick={() => activeMiddleTab = tab.id}
>
{tab.icon} {tab.label}
</button>
{/each}
</div>
<div class="panel-content">
{#await getPanel(activeMiddleTab) then mod}
<svelte:component this={mod.default} />
{:catch}
<p style="padding:1rem;color:var(--text-secondary)">Panel laden...</p>
{/await}
</div>
</Pane>
<PaneResizer class="resizer">
<div class="resizer-line"></div>
</PaneResizer>
<!-- Agents / Guard-Rails -->
<Pane defaultSize={25} minSize={10} class="panel">
<div class="panel-tabs">
{#each rightTabs as tab}
<button
class="tab"
class:active={activeRightTab === tab.id}
onclick={() => activeRightTab = tab.id}
>
{tab.icon} {tab.label}
</button>
{/each}
</div>
<div class="panel-content">
{#await getPanel(activeRightTab) then mod}
<svelte:component this={mod.default} />
{:catch}
<p style="padding:1rem;color:var(--text-secondary)">Panel laden...</p>
{/await}
</div>
</Pane>
</PaneGroup>
{/key}
<QuickActions bind:visible={showQuickActions} onExecute={handleQuickAction} />
<style>
/* PaneForge Container */
:global(.pane-group) {
.layout {
display: flex;
height: 100%;
min-height: 0;
background: var(--bg-primary);
}
:global(.panel) {
.main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: hidden;
}
/* Resizer Handle */
:global(.resizer) {
width: 8px;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: col-resize;
transition: background 0.15s ease;
}
:global(.resizer:hover),
:global(.resizer[data-state="drag"]) {
background: var(--accent);
}
.resizer-line {
width: 2px;
height: 24px;
border-radius: 1px;
background: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s;
}
:global(.resizer:hover) .resizer-line,
:global(.resizer[data-state="drag"]) .resizer-line {
opacity: 0.5;
}
/* Tabs */
.panel-tabs {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
.detached {
flex: 1;
padding: var(--spacing-sm);
font-size: 0.75rem;
color: var(--text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.panel-content {
flex: 1;
overflow: auto;
}
/* Detached-Chat Placeholder */
.detached-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
gap: var(--sp-3);
color: var(--text-secondary);
}
.detached-icon {
font-size: 2rem;
opacity: 0.4;
}
.detached-placeholder p {
font-size: 0.8rem;
margin: 0;
opacity: 0.6;
}
.reattach-btn {
margin-top: 8px;
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.reattach-btn:hover {
.reattach {
padding: var(--sp-2) var(--sp-4);
background: var(--accent);
color: white;
border-color: var(--accent);
color: var(--accent-fg);
border: 0;
border-radius: var(--r-sm);
font-size: var(--fs-md);
cursor: pointer;
}
.reattach:hover {
background: var(--accent-hover);
}
</style>