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
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:
parent
4780128c6f
commit
ad9833fcb8
43 changed files with 3406 additions and 764 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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)
|
||||
|
|
|
|||
45
ROADMAP.md
45
ROADMAP.md
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
260
src/app.css
260
src/app.css
|
|
@ -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,6 +208,30 @@ a:hover {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Animationen */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
|
@ -150,16 +243,21 @@ a:hover {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--bg-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,6 @@
|
|||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: #f59e0b;
|
||||
background: var(--status-warning);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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,185 +1118,50 @@
|
|||
</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>
|
||||
<!-- 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="detach-btn" onclick={() => invoke('chat_window_open')} title="Chat herausloesen">⧉</button>
|
||||
<button class="tool-btn" onclick={() => invoke('chat_window_open')} title="Chat herauslösen">⧉</button>
|
||||
{:else}
|
||||
<button class="detach-btn" onclick={() => invoke('chat_window_close')} title="Zurueck ins Hauptfenster">⧇</button>
|
||||
<button class="tool-btn" onclick={() => invoke('chat_window_close')} title="Zurück ins Hauptfenster">⧇</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 class="chat-messages-wrap" bind:this={messagesContainer}>
|
||||
<MessageList
|
||||
{streamingMessageId}
|
||||
onEdit={handleEditById}
|
||||
onRegenerate={handleRegenerateById}
|
||||
onRemember={openRememberDialog}
|
||||
onRewind={handleRewindById}
|
||||
/>
|
||||
</div>
|
||||
{: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}
|
||||
|
||||
<!-- 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 & Senden
|
||||
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}
|
||||
{/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
|
||||
</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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
162
src/lib/components/ChatStatusBar.svelte
Normal file
162
src/lib/components/ChatStatusBar.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
|
||||
.status.connected {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.error {
|
||||
|
|
|
|||
375
src/lib/components/Message.svelte
Normal file
375
src/lib/components/Message.svelte
Normal 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>
|
||||
134
src/lib/components/MessageList.svelte
Normal file
134
src/lib/components/MessageList.svelte
Normal 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>
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -328,7 +328,7 @@
|
|||
|
||||
.info-card.running {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
154
src/lib/components/Sidebar.svelte
Normal file
154
src/lib/components/Sidebar.svelte
Normal 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>
|
||||
209
src/lib/components/StatusBar.svelte
Normal file
209
src/lib/components/StatusBar.svelte
Normal 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>
|
||||
167
src/lib/components/ToolCallCard.svelte
Normal file
167
src/lib/components/ToolCallCard.svelte
Normal 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>
|
||||
28
src/lib/components/ToolCardAuto.svelte
Normal file
28
src/lib/components/ToolCardAuto.svelte
Normal 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}
|
||||
133
src/lib/components/ToolCardBash.svelte
Normal file
133
src/lib/components/ToolCardBash.svelte
Normal 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>
|
||||
94
src/lib/components/ToolCardEdit.svelte
Normal file
94
src/lib/components/ToolCardEdit.svelte
Normal 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>
|
||||
95
src/lib/components/ToolCardGeneric.svelte
Normal file
95
src/lib/components/ToolCardGeneric.svelte
Normal 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>
|
||||
129
src/lib/components/ToolCardRead.svelte
Normal file
129
src/lib/components/ToolCardRead.svelte
Normal 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>
|
||||
126
src/lib/components/ToolDrawer.svelte
Normal file
126
src/lib/components/ToolDrawer.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
130
src/lib/theme/vscode.css
Normal 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
60
src/lib/ui/Badge.svelte
Normal 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
119
src/lib/ui/Button.svelte
Normal 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
47
src/lib/ui/Card.svelte
Normal 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
111
src/lib/ui/Drawer.svelte
Normal 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
26
src/lib/ui/Icon.svelte
Normal 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} />
|
||||
45
src/lib/ui/StatusDot.svelte
Normal file
45
src/lib/ui/StatusDot.svelte
Normal 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
94
src/lib/ui/Tabs.svelte
Normal 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
74
src/lib/ui/Tooltip.svelte
Normal 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
13
src/lib/ui/index.ts
Normal 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
56
src/lib/utils/markdown.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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
122
src/lib/utils/toolCards.ts
Normal 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 '';
|
||||
}
|
||||
|
|
@ -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}
|
||||
<Tooltip text="Schulungsmodus">
|
||||
<button
|
||||
class="teach-btn"
|
||||
title="Schulungsmodus (Präsentations-Fenster)"
|
||||
class="icon-btn"
|
||||
onclick={() => invoke('presentation_open').catch(e => console.error(e))}
|
||||
>
|
||||
🎓
|
||||
<Icon icon={GraduationCap} size={16} />
|
||||
</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>
|
||||
{#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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
let activeDrawer = $state<DrawerSection | null>(null);
|
||||
let showQuickActions = $state(false);
|
||||
|
||||
function toggleDrawer(section: DrawerSection) {
|
||||
activeDrawer = activeDrawer === section ? null : section;
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
showQuickActions = true;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Detach/Reattach
|
||||
await listen('chat-reattached', () => { $chatDetached = false; });
|
||||
await listen('chat-detached', () => { $chatDetached = true; });
|
||||
|
||||
// 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);
|
||||
|
||||
// Quick-Actions Navigation: Drawer-Sektion bei Sub-Tab-Wahl oeffnen
|
||||
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;
|
||||
}
|
||||
});
|
||||
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';
|
||||
});
|
||||
|
||||
// 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 }>>;
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
// 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]();
|
||||
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)
|
||||
);
|
||||
}
|
||||
return loadedPanels[id];
|
||||
}
|
||||
|
||||
let activeMiddleTab = 'activity';
|
||||
let activeRightTab = 'agents';
|
||||
|
||||
// Sofort die Default-Tabs vorladen (nach dem kritischen Pfad)
|
||||
$: void getPanel(activeMiddleTab);
|
||||
$: void getPanel(activeRightTab);
|
||||
|
||||
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: '🖥️' },
|
||||
];
|
||||
|
||||
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: '⚙️' },
|
||||
];
|
||||
</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 -->
|
||||
<div class="main">
|
||||
{#if !$chatDetached}
|
||||
<Pane defaultSize={35} minSize={15} class="panel">
|
||||
<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')}>
|
||||
<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}
|
||||
|
||||
<PaneResizer class="resizer">
|
||||
<div class="resizer-line"></div>
|
||||
</PaneResizer>
|
||||
|
||||
<!-- 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>
|
||||
<ToolDrawer
|
||||
section={activeDrawer}
|
||||
onClose={() => (activeDrawer = null)}
|
||||
/>
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue