feat: Schulungsmodus Datei-Animationen + Permission-Toggle + Chat-Scroll-Fix [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m18s

- AnimatedFileEdit.svelte: neue Komponente fuer animierte Datei-Aenderungen im Praesentation-Fenster
- Schulungsmodus: 5-Stufen-Speed-Regler (Lehrer 10cps bis Data-Modus instant+Glow)
- Schulungsmodus: Live-Catchup-Button, Auto-Weiter nach Slide-Abschluss
- ChatPanel: Permission-Mode-Toggle links vom Textfeld (default/acceptEdits/bypassPermissions)
- ApprovalBar: Floating-Card mit blauem Glow, Buttons umbenannt (Anwenden/Ablehnen)
- MessageList: Scroll-Guard mit scrollend-Event + 700ms-Fallback statt doppeltem rAF
- MessageList: User-Nachrichten scrollen sofort nach unten (requestAnimationFrame + force)
- Message.svelte: MessagePart[]-basiertes Rendering fuer chronologische Reihenfolge
- events.ts: file-change sendet Slide an Praesentation-Fenster wenn offen
- teaching.rs: presentation_send_slide_if_open Command
- claude.rs: set/get_permission_mode Commands mit DB-Persistenz

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-27 22:13:52 +02:00
parent 79f4f9fb21
commit 71ab5ec830
12 changed files with 888 additions and 85 deletions

View file

@ -50,6 +50,12 @@ const CHANGE_TOOLS = ['Edit', 'Write'];
// Agent-Modus (solo | handlanger | experten | auto) // Agent-Modus (solo | handlanger | experten | auto)
let agentMode = 'solo'; let agentMode = 'solo';
// Permission-Modus (default | acceptEdits | bypassPermissions) — Phase 11.
// Wird ueber den Permission-Toggle links vom Textfeld umgeschaltet und an
// queryOptions weitergereicht damit Edits / Bash automatisch akzeptiert
// werden koennen wenn der User das will.
let permissionMode = 'default';
// Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert // Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert
let stickyContext = ''; let stickyContext = '';
@ -463,6 +469,12 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
queryOptions.tools = { type: 'preset', preset: 'claude_code' }; queryOptions.tools = { type: 'preset', preset: 'claude_code' };
queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash']; queryOptions.allowedTools = ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'];
// Phase 11: Permission-Modus aus dem Toggle-Knopf links vom Textfeld.
// 'default' nicht setzen — SDK fragt dann ueber den Approval-Hook.
if (permissionMode !== 'default') {
queryOptions.permissionMode = permissionMode;
}
if (effectiveMode === 'handlanger') { if (effectiveMode === 'handlanger') {
sendMonitorEvent('agent', 'Handlanger: Delegation per System-Prompt durchgesetzt', { sendMonitorEvent('agent', 'Handlanger: Delegation per System-Prompt durchgesetzt', {
mode: effectiveMode, mode: effectiveMode,
@ -913,6 +925,20 @@ function handleCommand(msg) {
sendMonitorEvent('agent', `Agent-Modus geändert: ${agentMode}`, { mode: agentMode }); sendMonitorEvent('agent', `Agent-Modus geändert: ${agentMode}`, { mode: agentMode });
break; break;
case 'set-permission-mode': {
// Permission-Modus setzen (default, acceptEdits, bypassPermissions)
const validPerms = ['default', 'acceptEdits', 'bypassPermissions'];
if (!msg.mode || !validPerms.includes(msg.mode)) {
sendError(msg.id, `Ungültiger Permission-Modus: ${msg.mode}. Verfügbar: ${validPerms.join(', ')}`);
return;
}
permissionMode = msg.mode;
sendResponse(msg.id, { mode: permissionMode, status: 'Permission-Modus geändert' });
sendEvent('permission-mode-changed', { mode: permissionMode });
sendMonitorEvent('agent', `Permission-Modus geändert: ${permissionMode}`, { mode: permissionMode });
break;
}
case 'get-mode': case 'get-mode':
sendResponse(msg.id, { mode: agentMode }); sendResponse(msg.id, { mode: agentMode });
break; break;

View file

@ -612,7 +612,7 @@ fn send_to_bridge_full(
"id": request_id, "id": request_id,
"model": message "model": message
}), }),
"set-mode" => serde_json::json!({ "set-mode" | "set-permission-mode" => serde_json::json!({
"command": command, "command": command,
"id": request_id, "id": request_id,
"mode": message "mode": message
@ -921,6 +921,48 @@ pub async fn get_agent_mode(app: AppHandle) -> Result<String, String> {
Ok("solo".to_string()) Ok("solo".to_string())
} }
/// Permission-Modus setzen (default | acceptEdits | bypassPermissions).
/// Wird vom Toggle-Knopf links vom Textfeld aufgerufen.
#[tauri::command]
pub async fn set_permission_mode(app: AppHandle, mode: String) -> Result<String, String> {
let valid = ["default", "acceptEdits", "bypassPermissions"];
if !valid.contains(&mode.as_str()) {
return Err(format!("Ungültiger Permission-Modus: {}. Verfügbar: {}", mode, valid.join(", ")));
}
println!("🔐 Permission-Modus wechseln zu: {}", mode);
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
let db = db_state.lock().unwrap();
let _ = db.set_setting("permission_mode", &mode);
}
let needs_start = {
let state = app.state::<Arc<Mutex<ClaudeState>>>();
let s = state.lock().unwrap();
!s.is_connected()
};
if needs_start {
start_bridge(&app)?;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
send_to_bridge(&app, "set-permission-mode", &mode)?;
Ok(mode)
}
/// Aktuellen Permission-Modus aus Settings laden.
#[tauri::command]
pub async fn get_permission_mode(app: AppHandle) -> Result<String, String> {
if let Some(db_state) = app.try_state::<Arc<Mutex<crate::db::Database>>>() {
let db = db_state.lock().unwrap();
if let Ok(Some(mode)) = db.get_setting("permission_mode") {
return Ok(mode);
}
}
Ok("default".to_string())
}
/// Modell-Info Struct /// Modell-Info Struct
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModelInfo { pub struct ModelInfo {

View file

@ -55,6 +55,8 @@ pub fn run() {
claude::get_current_model, claude::get_current_model,
claude::set_agent_mode, claude::set_agent_mode,
claude::get_agent_mode, claude::get_agent_mode,
claude::set_permission_mode,
claude::get_permission_mode,
claude::init_sticky_context, claude::init_sticky_context,
claude::get_bridge_status, claude::get_bridge_status,
claude::stop_bridge_daemon, claude::stop_bridge_daemon,
@ -189,6 +191,7 @@ pub fn run() {
teaching::presentation_open, teaching::presentation_open,
teaching::presentation_close, teaching::presentation_close,
teaching::presentation_send_slide, teaching::presentation_send_slide,
teaching::presentation_send_slide_if_open,
teaching::presentation_clear, teaching::presentation_clear,
// Chat-Fenster (herauslösen) // Chat-Fenster (herauslösen)
chat_window::chat_window_open, chat_window::chat_window_open,

View file

@ -6,12 +6,21 @@ use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Slide { pub struct Slide {
pub r#type: String, // "mermaid" | "code" | "text" pub r#type: String, // "mermaid" | "code" | "text" | "file-edit"
#[serde(default, skip_serializing_if = "String::is_empty")]
pub content: String, pub content: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>, pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>, pub title: Option<String>,
// file-edit Felder (Phase 11): Datei-Aenderungen werden in der
// Praesentation animiert nachgespielt.
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
} }
#[tauri::command] #[tauri::command]
@ -62,3 +71,14 @@ pub async fn presentation_clear(app: AppHandle) -> Result<(), String> {
} }
Ok(()) Ok(())
} }
/// Schickt eine Slide nur wenn das Praesentations-Fenster bereits offen ist.
/// Wird vom file-change-Handler aufgerufen — wenn der User den Schulungsmodus
/// nicht aktiv hat soll natuerlich nichts passieren.
#[tauri::command]
pub async fn presentation_send_slide_if_open(app: AppHandle, slide: Slide) -> Result<(), String> {
if let Some(win) = app.get_webview_window("presentation") {
win.emit("presentation-slide", &slide).map_err(|e| e.to_string())?;
}
Ok(())
}

View file

@ -0,0 +1,298 @@
<script lang="ts">
// Animierte Wiedergabe einer Datei-Aenderung im Schulungsmodus.
//
// Drei Phasen pro Edit:
// 1. Datei "oeffnen" → Header + before-Inhalt erscheint
// 2. Diff-Bereich → entfernte Zeilen rot ausstreichen, neue Zeilen
// zeichenweise tippen (oder bei Stufe 5 instant + Glow)
// 3. Highlight-Sweep → kurzer gruener Glow ueber den geaenderten Block
//
// Geschwindigkeit kommt als charsPerSecond rein (0 = instant).
import { onMount, onDestroy } from 'svelte';
interface Props {
filePath: string;
before: string;
after: string;
language?: string;
charsPerSecond?: number; // 0 = instant
paused?: boolean;
onDone?: () => void;
}
let {
filePath,
before,
after,
language = 'text',
charsPerSecond = 30,
paused = false,
onDone,
}: Props = $props();
type LineOp = { kind: 'keep' | 'add' | 'del'; text: string };
// Sehr einfacher Zeilen-Diff (keine Subsequenz-Optimierung — fuer den
// Schulungsmodus ist Genauigkeit weniger wichtig als Wirkung).
const ops = $derived.by((): LineOp[] => {
const a = before.split('\n');
const b = after.split('\n');
const result: LineOp[] = [];
let i = 0, j = 0;
while (i < a.length || j < b.length) {
if (i >= a.length) {
result.push({ kind: 'add', text: b[j++] });
} else if (j >= b.length) {
result.push({ kind: 'del', text: a[i++] });
} else if (a[i] === b[j]) {
result.push({ kind: 'keep', text: a[i] });
i++; j++;
} else {
// Schau ob die naechste a-Zeile in b kommt → del
const aInB = b.indexOf(a[i], j);
const bInA = a.indexOf(b[j], i);
if (aInB === -1 && bInA === -1) {
result.push({ kind: 'del', text: a[i++] });
result.push({ kind: 'add', text: b[j++] });
} else if (aInB === -1) {
result.push({ kind: 'del', text: a[i++] });
} else {
result.push({ kind: 'add', text: b[j++] });
}
}
}
return result;
});
// Zeichen die noch zu tippen sind in der naechsten add-Zeile.
let typedChars = $state(0);
let currentAddIdx = $state(-1); // index in ops der aktuell getippt wird
let phase = $state<'opening' | 'typing' | 'sweep' | 'done'>('opening');
let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
function clearTimer() {
if (timer !== null) { clearTimeout(timer); timer = null; }
}
function nextAddIdx(from: number): number {
const list = ops;
for (let k = from; k < list.length; k++) {
if (list[k].kind === 'add') return k;
}
return -1;
}
function startTyping() {
phase = 'typing';
currentAddIdx = nextAddIdx(0);
typedChars = 0;
tickType();
}
function tickType() {
if (cancelled || paused) return;
if (currentAddIdx < 0) {
phase = 'sweep';
timer = setTimeout(finish, 800);
return;
}
const target = ops[currentAddIdx].text;
if (typedChars >= target.length) {
// Naechste add-Zeile
currentAddIdx = nextAddIdx(currentAddIdx + 1);
typedChars = 0;
if (currentAddIdx < 0) {
phase = 'sweep';
timer = setTimeout(finish, 800);
return;
}
}
typedChars++;
const delay = charsPerSecond > 0 ? 1000 / charsPerSecond : 0;
if (delay <= 1) {
// Effektiv instant — alle add-Zeilen auf einmal vervollstaendigen
let idx = currentAddIdx;
while (idx >= 0) {
typedChars = ops[idx].text.length;
idx = nextAddIdx(idx + 1);
if (idx < 0) break;
currentAddIdx = idx;
typedChars = 0;
}
currentAddIdx = -1;
phase = 'sweep';
timer = setTimeout(finish, 600);
} else {
timer = setTimeout(tickType, delay);
}
}
function finish() {
phase = 'done';
onDone?.();
}
$effect(() => {
// Wenn paused waehrend typing geaendert wird → Timer stoppen
if (paused) clearTimer();
else if (phase === 'typing' && timer === null) tickType();
});
onMount(() => {
// Phase 1: Datei oeffnen
timer = setTimeout(startTyping, charsPerSecond > 200 ? 120 : 350);
});
onDestroy(() => {
cancelled = true;
clearTimer();
});
// Hilfsfunktion: gerenderten Text einer add-Zeile bis typedChars
function visibleAddText(idx: number, full: string): string {
if (idx !== currentAddIdx) return full;
return full.substring(0, typedChars);
}
</script>
<div class="file-edit" class:fast={charsPerSecond > 200}>
<div class="file-header" class:opening={phase === 'opening'}>
<span class="dot dot-r"></span>
<span class="dot dot-y"></span>
<span class="dot dot-g"></span>
<span class="path">{filePath}</span>
<span class="lang-tag">{language}</span>
</div>
<pre class="code"><code>{#each ops as op, i (i)}{#if op.kind === 'keep'}<span class="line keep">{op.text}
</span>{:else if op.kind === 'del'}<span class="line del">{op.text}
</span>{:else if op.kind === 'add'}<span class="line add" class:typing={i === currentAddIdx} class:swept={phase === 'sweep' || phase === 'done'}>{visibleAddText(i, op.text)}{#if i === currentAddIdx}<span class="caret"></span>{/if}
</span>{/if}{/each}</code></pre>
</div>
<style>
.file-edit {
background: #0f172a;
border: 1px solid #1e293b;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
max-width: 1100px;
width: 100%;
}
.file-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #1e293b;
border-bottom: 1px solid #334155;
font-family: 'JetBrains Mono', 'Cascadia Code', monospace;
font-size: 12px;
color: #94a3b8;
transform-origin: top left;
animation: header-open 280ms ease-out;
}
.file-header.opening { animation-duration: 380ms; }
@keyframes header-open {
from { transform: scaleY(0.4); opacity: 0; }
to { transform: scaleY(1); opacity: 1; }
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.dot-r { background: #ef4444; }
.dot-y { background: #f59e0b; }
.dot-g { background: #10b981; }
.path {
flex: 1;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.lang-tag {
color: #60a5fa;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.code {
margin: 0;
padding: 14px 16px;
font-family: 'JetBrains Mono', 'Cascadia Code', monospace;
font-size: 13.5px;
line-height: 1.55;
color: #e2e8f0;
max-height: 60vh;
overflow-y: auto;
white-space: pre;
}
.code code { background: transparent; color: inherit; }
.line {
display: inline-block;
width: 100%;
padding: 0 4px;
}
.line.keep { color: #94a3b8; }
.line.del {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
text-decoration: line-through;
text-decoration-color: rgba(239, 68, 68, 0.65);
animation: line-strike 360ms ease-out;
}
@keyframes line-strike {
from { background: rgba(239, 68, 68, 0); text-decoration-color: transparent; }
to { background: rgba(239, 68, 68, 0.18); text-decoration-color: rgba(239, 68, 68, 0.65); }
}
.line.add {
background: rgba(16, 185, 129, 0.15);
color: #d1fae5;
position: relative;
}
/* Im "Data"-Modus (cps > 200) blitzt die Add-Zeile als Ganzes auf */
.file-edit.fast .line.add {
animation: add-flash 280ms ease-out;
}
@keyframes add-flash {
0% { background: rgba(16, 185, 129, 0.6); color: #ffffff; box-shadow: inset 0 0 12px rgba(16, 185, 129, 0.6); }
100% { background: rgba(16, 185, 129, 0.15); color: #d1fae5; box-shadow: none; }
}
.line.add.swept {
animation: line-sweep 700ms ease-out;
}
@keyframes line-sweep {
0% { box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0); }
35% { box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.9); background: rgba(16, 185, 129, 0.32); }
100% { box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0); background: rgba(16, 185, 129, 0.15); }
}
.caret {
display: inline-block;
color: #34d399;
font-weight: 700;
animation: caret-blink 0.7s steps(2, end) infinite;
text-shadow: 0 0 6px #34d399;
}
@keyframes caret-blink {
50% { opacity: 0.2; }
}
</style>

View file

@ -32,7 +32,7 @@
await invoke('accept_change', { toolId }); await invoke('accept_change', { toolId });
pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId)); pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId));
} catch (err) { } catch (err) {
addMessage('system', `Fehler beim Übernehmen: ${err}`); addMessage('system', `Fehler beim Anwenden: ${err}`);
} finally { } finally {
busy = false; busy = false;
} }
@ -46,7 +46,7 @@
pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId)); pendingChanges.update((list) => list.filter((c) => c.toolId !== toolId));
addMessage('system', `↩️ ${result}`); addMessage('system', `↩️ ${result}`);
} catch (err) { } catch (err) {
addMessage('system', `Fehler beim Verwerfen: ${err}`); addMessage('system', `Fehler beim Ablehnen: ${err}`);
} finally { } finally {
busy = false; busy = false;
} }
@ -148,7 +148,7 @@
disabled={busy} disabled={busy}
title="Änderung verwerfen — Datei bleibt unverändert (Ctrl+Backspace)" title="Änderung verwerfen — Datei bleibt unverändert (Ctrl+Backspace)"
> >
Verwerfen Ablehnen
</button> </button>
<button <button
class="btn btn-accept" class="btn btn-accept"
@ -157,7 +157,7 @@
disabled={busy} disabled={busy}
title="Änderung auf die Datei anwenden (Ctrl+Enter)" title="Änderung auf die Datei anwenden (Ctrl+Enter)"
> >
Übernehmen Anwenden
</button> </button>
{:else} {:else}
<button <button
@ -200,7 +200,7 @@
type="button" type="button"
onclick={() => reject(change.toolId)} onclick={() => reject(change.toolId)}
disabled={busy} disabled={busy}
title="Verwerfen" title="Ablehnen"
> >
</button> </button>
@ -209,7 +209,7 @@
type="button" type="button"
onclick={() => accept(change.toolId)} onclick={() => accept(change.toolId)}
disabled={busy} disabled={busy}
title="Übernehmen" title="Anwenden"
> >
</button> </button>
@ -222,20 +222,44 @@
{/if} {/if}
<style> <style>
/* Phase 11: aus Sticky-Bar wurde ein Approval-Card mit eigenem Standing —
prominenter, mit Glow-Frame, klar abgesetzt vom Chat-Stream. Bleibt
trotzdem oberhalb des Eingabefelds, damit der User Claude weiter
sieht und auf die Anfrage reagieren kann ohne Kontext zu verlieren. */
.approval-bar { .approval-bar {
flex-shrink: 0; flex-shrink: 0;
margin: 8px 12px;
background: var(--bg-secondary, #252526); background: var(--bg-secondary, #252526);
border-top: 2px solid var(--accent, #007acc); border: 1px solid var(--accent, #007acc);
border-bottom: 1px solid var(--border, #3c3c3c); border-radius: 6px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.18); box-shadow:
font-size: 12px; 0 6px 18px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(0, 122, 204, 0.25),
0 0 24px rgba(0, 122, 204, 0.18);
font-size: 12.5px;
animation: card-glow 2.4s ease-in-out infinite;
}
@keyframes card-glow {
0%, 100% {
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(0, 122, 204, 0.25),
0 0 24px rgba(0, 122, 204, 0.18);
}
50% {
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(0, 122, 204, 0.45),
0 0 32px rgba(0, 122, 204, 0.32);
}
} }
.bar-main { .bar-main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 6px 12px; padding: 10px 14px;
gap: 12px; gap: 12px;
} }
@ -311,11 +335,11 @@
} }
.btn { .btn {
padding: 4px 12px; padding: 6px 14px;
border-radius: 3px; border-radius: 4px;
border: 1px solid transparent; border: 1px solid transparent;
font-size: 12px; font-size: 12.5px;
font-weight: 500; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event'; import { emit, listen } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, pendingChanges, type Message, type QuickAction, type FileChange } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, pendingChanges, permissionMode, type Message, type QuickAction, type FileChange, type PermissionMode } from '$lib/stores/app';
import { currentTool, processingPhase } from '$lib/stores/events'; import { currentTool, processingPhase } from '$lib/stores/events';
import { marked, type Tokens } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte'; import { tick, onDestroy, onMount } from 'svelte';
@ -790,6 +790,21 @@
// Legacy-Queue Subscriber (Sicherheitsnetz — Hauptlogik ist jetzt in der Bridge) // Legacy-Queue Subscriber (Sicherheitsnetz — Hauptlogik ist jetzt in der Bridge)
const unsubProcessing = isProcessing.subscribe(() => {}); const unsubProcessing = isProcessing.subscribe(() => {});
// Phase 11: Permission-Modus aus Settings wiederherstellen, damit der
// Toggle-Knopf links vom Textfeld den letzten Stand zeigt und die
// Bridge ihn beim ersten send_message direkt verwendet.
try {
const savedMode = await invoke<string>('get_permission_mode');
if (savedMode === 'default' || savedMode === 'acceptEdits' || savedMode === 'bypassPermissions') {
permissionMode.set(savedMode);
if (savedMode !== 'default') {
invoke('set_permission_mode', { mode: savedMode }).catch(() => {});
}
}
} catch (e) {
console.debug('Permission-Modus laden fehlgeschlagen:', e);
}
return () => { return () => {
window.removeEventListener('keydown', handleGlobalKeydown); window.removeEventListener('keydown', handleGlobalKeydown);
unsubProcessing(); unsubProcessing();
@ -1260,6 +1275,34 @@
<span class="transcript-text">{liveTranscript}</span> <span class="transcript-text">{liveTranscript}</span>
</div> </div>
{/if} {/if}
<div class="input-prefix">
<button
class="perm-toggle"
class:active={$permissionMode !== 'default'}
class:yolo={$permissionMode === 'bypassPermissions'}
onclick={() => {
const next: PermissionMode =
$permissionMode === 'default' ? 'acceptEdits'
: $permissionMode === 'acceptEdits' ? 'bypassPermissions'
: 'default';
permissionMode.set(next);
invoke('set_permission_mode', { mode: next }).catch((e) => console.warn('set_permission_mode:', e));
}}
title={$permissionMode === 'default'
? 'Approval-Modus: Nachfragen vor jedem Edit/Bash. Klick: Edits automatisch.'
: $permissionMode === 'acceptEdits'
? 'Approval-Modus: Edits automatisch, Bash fragt nach. Klick: alles automatisch (Yolo).'
: 'Yolo: alles laeuft ohne Nachfrage. Klick: Approval-Modus aus.'}
>
{#if $permissionMode === 'default'}
🔒
{:else if $permissionMode === 'acceptEdits'}
🔓
{:else}
{/if}
</button>
</div>
<textarea <textarea
bind:this={inputTextarea} bind:this={inputTextarea}
bind:value={$currentInput} bind:value={$currentInput}
@ -2097,6 +2140,45 @@
overflow-y: auto; overflow-y: auto;
} }
/* Phase 11: Toggle-Spalte links vom Textfeld (Approval-Modus). */
.input-prefix {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
align-self: flex-end;
}
.perm-toggle {
width: 36px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: 1.15rem;
cursor: pointer;
transition: all 0.18s ease;
}
.perm-toggle:hover {
background: var(--bg-tertiary);
}
.perm-toggle.active {
background: rgba(0, 122, 204, 0.15);
border-color: var(--accent, #007acc);
box-shadow: 0 0 0 1px var(--accent, #007acc);
}
.perm-toggle.yolo {
background: rgba(244, 135, 113, 0.18);
border-color: var(--status-error, #f48771);
box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 12px rgba(244, 135, 113, 0.35);
animation: yolo-pulse 1.6s ease-in-out infinite;
}
@keyframes yolo-pulse {
0%, 100% { box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 12px rgba(244, 135, 113, 0.30); }
50% { box-shadow: 0 0 0 1px var(--status-error, #f48771), 0 0 20px rgba(244, 135, 113, 0.55); }
}
.send-button { .send-button {
width: 48px; width: 48px;
height: 48px; height: 48px;

View file

@ -7,7 +7,7 @@
// Hover-Actions rechts oben: Edit (User), Regenerate (letzte Assistant), // Hover-Actions rechts oben: Edit (User), Regenerate (letzte Assistant),
// Copy, Das-merken, Rewind (Assistant). // Copy, Das-merken, Rewind (Assistant).
import { messages, type Message, type InlineToolCall, type KnowledgeHint } from '$lib/stores'; import { messages, type Message, type InlineToolCall, type KnowledgeHint, type MessagePart } from '$lib/stores';
import { processingPhase } from '$lib/stores/events'; import { processingPhase } from '$lib/stores/events';
import { renderMarkdown } from '$lib/utils/markdown'; import { renderMarkdown } from '$lib/utils/markdown';
import ToolCardAuto from './ToolCardAuto.svelte'; import ToolCardAuto from './ToolCardAuto.svelte';
@ -80,6 +80,25 @@
const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []); const toolCalls = $derived<InlineToolCall[]>(message.toolCalls || []);
const knowledgeHints = $derived<KnowledgeHint[]>(message.knowledgeHints || []); const knowledgeHints = $derived<KnowledgeHint[]>(message.knowledgeHints || []);
// Phase 11: chronologische Render-Liste. Wenn parts gesetzt sind (neue
// Messages + DB-rehydrierte), nutzen wir sie. Sonst Fallback auf alte
// Aufteilung (kann nach voller Migration entfallen).
const renderParts = $derived.by((): MessagePart[] => {
if (message.parts && message.parts.length > 0) return message.parts;
const fallback: MessagePart[] = [];
for (const c of toolCalls) fallback.push({ type: 'tool', call: c });
if (message.content) fallback.push({ type: 'text', content: message.content });
return fallback;
});
// Index des LETZTEN text-parts — nur dort darf der Streaming-Cursor stehen.
const lastTextIdx = $derived.by(() => {
for (let i = renderParts.length - 1; i >= 0; i--) {
if (renderParts[i].type === 'text') return i;
}
return -1;
});
// Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung, // Copy-Buttons fuer Code-Bloecke (sicher: nur Effekt auf content-Aenderung,
// kein MutationObserver — verhindert OOM bei langem Streaming) // kein MutationObserver — verhindert OOM bei langem Streaming)
let contentEl: HTMLDivElement | null = null; let contentEl: HTMLDivElement | null = null;
@ -154,9 +173,9 @@
</span> </span>
</header> </header>
<!-- Reihenfolge: erst die Hintergrund-Aktionen (KB-Hints, Tools, Gedanken), <!-- KB-Hints sind Hintergrund-Hinweise (kein Stream-Event), daher oben.
dann die finale Antwort. Spiegelt den echten zeitlichen Ablauf: Alles Andere (Text, Tool-Calls, eingebettete Gedanken) wird ueber
Claude liest KB → ruft Tools → schreibt Antwort. --> parts in echter chronologischer Reihenfolge gerendert. -->
{#if knowledgeHints.length > 0} {#if knowledgeHints.length > 0}
<div class="kb-hints"> <div class="kb-hints">
{#each knowledgeHints as hint (hint.id)} {#each knowledgeHints as hint (hint.id)}
@ -165,20 +184,22 @@
</div> </div>
{/if} {/if}
{#if toolCalls.length > 0} {#if renderParts.length > 0}
<div class="tool-calls">
{#each toolCalls as call (call.id)}
<ToolCardAuto {call} />
{/each}
</div>
{/if}
{#if message.content}
<div class="content" bind:this={contentEl}> <div class="content" bind:this={contentEl}>
{@html renderMarkdown(message.content)} {#each renderParts as part, i (i)}
{#if isStreaming} {#if part.type === 'text'}
<span class="cursor"></span> <div class="text-part">
{/if} {@html renderMarkdown(part.content)}
{#if isStreaming && i === lastTextIdx}
<span class="cursor"></span>
{/if}
</div>
{:else if part.type === 'tool'}
<div class="tool-part">
<ToolCardAuto call={part.call} />
</div>
{/if}
{/each}
</div> </div>
{:else if isStreaming} {:else if isStreaming}
<div class="content faint"></div> <div class="content faint"></div>
@ -396,6 +417,19 @@
gap: 1px; gap: 1px;
} }
/* Phase 11: einzelner Tool-Part im chronologischen Strom */
.tool-part {
margin: 6px 0;
}
.text-part + .tool-part,
.tool-part + .text-part,
.tool-part + .tool-part {
margin-top: 4px;
}
.text-part:not(:first-child) {
margin-top: 4px;
}
.kb-hints { .kb-hints {
margin: 2px 0 4px 0; margin: 2px 0 4px 0;
display: flex; display: flex;

View file

@ -31,6 +31,7 @@
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let userScrolledUp = $state(false); let userScrolledUp = $state(false);
let autoScrolling = false; // Guard: ignoriert onscroll waehrend wir selbst scrollen let autoScrolling = false; // Guard: ignoriert onscroll waehrend wir selbst scrollen
let autoScrollTimer: ReturnType<typeof setTimeout> | null = null;
let resizeObs: ResizeObserver | null = null; let resizeObs: ResizeObserver | null = null;
// Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine // Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine
@ -52,22 +53,40 @@
if (next !== userScrolledUp) userScrolledUp = next; if (next !== userScrolledUp) userScrolledUp = next;
} }
function releaseAutoScroll() {
if (autoScrollTimer) {
clearTimeout(autoScrollTimer);
autoScrollTimer = null;
}
container?.removeEventListener('scrollend', releaseAutoScroll);
autoScrolling = false;
}
function scrollToBottom(force = false) { function scrollToBottom(force = false) {
if (!container) return; if (!container) return;
if (!force && userScrolledUp) return; if (!force && userScrolledUp) return;
// Vorigen Lock aufraeumen, sonst stapeln sich Timer/Listener
releaseAutoScroll();
autoScrolling = true; autoScrolling = true;
// Smooth nur bei kleinen Distanzen — bei grossem Stream-Catch-up wuerde // Smooth nur bei kleinen Distanzen — bei grossem Stream-Catch-up wuerde
// das die Anzeige ausbremsen, also dort instant. // das die Anzeige ausbremsen, also dort instant.
const distance = container.scrollHeight - container.scrollTop - container.clientHeight; const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distance < 240 && !force) { const useSmooth = distance < 240 && !force;
if (useSmooth) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
// Smooth-Scroll dauert 200-500ms — den Guard erst loesen wenn der
// Browser fertig ist. 'scrollend' deckt den Normalfall ab,
// setTimeout ist Fallback fuer Edge-Cases (Tab im Hintergrund,
// Container vor Ablauf neu gemountet, scrollend wird verschluckt).
container.addEventListener('scrollend', releaseAutoScroll, { once: true });
autoScrollTimer = setTimeout(releaseAutoScroll, 700);
} else { } else {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
// Instant-Scroll: ein rAF reicht damit das onscroll-Event durch ist
requestAnimationFrame(() => {
requestAnimationFrame(releaseAutoScroll);
});
} }
// autoScrolling nach dem naechsten Frame zuruecksetzen
requestAnimationFrame(() => {
requestAnimationFrame(() => { autoScrolling = false; });
});
} }
function snapToBottom() { function snapToBottom() {
@ -100,8 +119,12 @@
const role = last?.role ?? null; const role = last?.role ?? null;
if (role && role !== lastRole) { if (role && role !== lastRole) {
if (role === 'user') { if (role === 'user') {
// Beim Senden: wieder ans Ende kleben // Beim Senden: wieder ans Ende kleben — und sofort scrollen,
// nicht auf den naechsten Stream-Token warten. Force=true geht
// am userScrolledUp-Guard vorbei. Ein rAF, damit das DOM die
// neue Message schon gerendert hat.
userScrolledUp = false; userScrolledUp = false;
requestAnimationFrame(() => scrollToBottom(true));
} }
lastRole = role; lastRole = role;
} }
@ -138,6 +161,7 @@
resizeObs.disconnect(); resizeObs.disconnect();
resizeObs = null; resizeObs = null;
} }
releaseAutoScroll();
}); });
</script> </script>

View file

@ -39,18 +39,74 @@ export interface InlineToolCall {
completedAt?: Date; completedAt?: Date;
} }
// Phase 11: Chronologisch sortierte Stream-Teile einer Assistant-Message.
// Loest die alte Trennung in `content`/`toolCalls`/`knowledgeHints` auf —
// damit Tools jetzt zwischen Text-Stuecken erscheinen koennen, in der Reihenfolge
// in der sie tatsaechlich passiert sind.
export type MessagePart =
| { type: 'text'; content: string }
| { type: 'tool'; call: InlineToolCall };
export interface Message { export interface Message {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string; // aggregierter Text — fuer DB-Persistenz, FTS-Suche, Copy
parts?: MessagePart[]; // chronologische Stream-Parts (Renderpfad)
timestamp: Date; timestamp: Date;
agentId?: string; agentId?: string;
model?: string; model?: string;
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
toolCalls?: InlineToolCall[]; // Inline gerenderte Tool-Karten (Phase 8) toolCalls?: InlineToolCall[]; // Legacy: einige Stellen lesen noch hier — wird parallel gepflegt
knowledgeHints?: KnowledgeHint[]; // KB-Treffer die fuer diese Antwort herangezogen wurden knowledgeHints?: KnowledgeHint[]; // KB-Treffer die fuer diese Antwort herangezogen wurden
} }
// Hilfen fuer Stream-Handler in events.ts.
// Text an die parts anhaengen: letzten text-part erweitern oder neuen anlegen.
export function appendTextToParts(parts: MessagePart[] | undefined, text: string): MessagePart[] {
const list = parts ? [...parts] : [];
if (list.length > 0) {
const last = list[list.length - 1];
if (last.type === 'text') {
list[list.length - 1] = { type: 'text', content: last.content + text };
return list;
}
}
list.push({ type: 'text', content: text });
return list;
}
// Tool-Part anfuegen (status='running').
export function appendToolToParts(parts: MessagePart[] | undefined, call: InlineToolCall): MessagePart[] {
const list = parts ? [...parts] : [];
list.push({ type: 'tool', call });
return list;
}
// Tool-Part finalisieren (status, result, completedAt setzen).
export function updateToolInParts(
parts: MessagePart[] | undefined,
toolId: string,
patch: Partial<InlineToolCall>
): MessagePart[] | undefined {
if (!parts) return parts;
let changed = false;
const next = parts.map((p) => {
if (p.type === 'tool' && p.call.id === toolId) {
changed = true;
return { type: 'tool' as const, call: { ...p.call, ...patch } };
}
return p;
});
return changed ? next : parts;
}
// content-String aus parts aggregieren (fuer DB-Persistenz beim Result-Event).
export function partsToContent(parts: MessagePart[] | undefined): string {
if (!parts) return '';
return parts.filter((p) => p.type === 'text').map((p) => (p as { content: string }).content).join('');
}
export interface Permission { export interface Permission {
id: string; id: string;
pattern: string; pattern: string;
@ -90,6 +146,13 @@ export const toolCalls = writable<ToolCall[]>([]);
export const messages = writable<Message[]>([]); export const messages = writable<Message[]>([]);
export const permissions = writable<Permission[]>([]); export const permissions = writable<Permission[]>([]);
// Phase 11: Approval-Modus.
// - 'default' = Approval-Bar fragt bei jedem Edit/Bash
// - 'acceptEdits' = Datei-Edits laufen automatisch durch, Bash bleibt Approval-pflichtig
// - 'bypassPermissions' = alles automatisch (Yolo) — Guard-Rails greifen nicht mehr
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
export const permissionMode = writable<PermissionMode>('default');
// UI-State // UI-State
export const isProcessing = writable(false); export const isProcessing = writable(false);
export const chatDetached = writable(false); export const chatDetached = writable(false);
@ -206,6 +269,7 @@ export function addMessage(role: Message['role'], content: string, agentId?: str
id: crypto.randomUUID(), id: crypto.randomUUID(),
role, role,
content, content,
parts: content ? [{ type: 'text', content }] : [],
timestamp: new Date(), timestamp: new Date(),
agentId agentId
} }
@ -354,10 +418,17 @@ export function messageToDb(msg: Message, sessionId: string): DbMessage {
// Konvertierung: DB → Store // Konvertierung: DB → Store
export function dbToMessage(db: DbMessage): Message { export function dbToMessage(db: DbMessage): Message {
const role = db.role as Message['role'];
// Beim Reload aus der DB sind nur text-Parts wiederherstellbar — Tool-Calls
// werden nicht persistiert (waren sie vorher auch nicht). Der Renderer
// laeuft trotzdem ueber parts, damit die Logik in beiden Faellen gleich ist.
const parts: MessagePart[] | undefined =
role === 'assistant' && db.content ? [{ type: 'text', content: db.content }] : undefined;
return { return {
id: db.id, id: db.id,
role: db.role as Message['role'], role,
content: db.content, content: db.content,
parts,
model: db.model || undefined, model: db.model || undefined,
agentId: db.agent_id || undefined, agentId: db.agent_id || undefined,
timestamp: new Date(db.timestamp), timestamp: new Date(db.timestamp),

View file

@ -26,6 +26,9 @@ import {
activeKnowledgeHints, activeKnowledgeHints,
agentMode, agentMode,
pendingChanges, pendingChanges,
appendTextToParts,
appendToolToParts,
updateToolInParts,
type Message, type Message,
type Agent, type Agent,
type MonitorEventType, type MonitorEventType,
@ -38,6 +41,20 @@ import {
// Aktuell laufendes Tool (für inline Aktivitätsanzeige) // Aktuell laufendes Tool (für inline Aktivitätsanzeige)
export const currentTool = writable<{ tool: string; input: Record<string, unknown> } | null>(null); export const currentTool = writable<{ tool: string; input: Record<string, unknown> } | null>(null);
// Heuristik: Sprach-Tag fuer Syntax-Highlighting im Schulungsfenster
function extractLanguageFromPath(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
rs: 'rust', py: 'python', go: 'go', java: 'java', kt: 'kotlin',
php: 'php', rb: 'ruby', sh: 'bash', bash: 'bash', zsh: 'bash',
json: 'json', toml: 'toml', yaml: 'yaml', yml: 'yaml',
html: 'html', css: 'css', scss: 'scss', svelte: 'svelte', vue: 'vue',
md: 'markdown', sql: 'sql', nix: 'nix',
};
return map[ext] || 'text';
}
// --- Inline-Tool-Cards in Assistant-Message (Phase 8) ---------------------- // --- Inline-Tool-Cards in Assistant-Message (Phase 8) ----------------------
// Haengt einen neuen Tool-Call an die letzte Assistant-Nachricht an. // Haengt einen neuen Tool-Call an die letzte Assistant-Nachricht an.
@ -56,7 +73,14 @@ function appendToolCallToLastAssistant(toolId: string, tool: string, input: Reco
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant') { if (msgs[i].role === 'assistant') {
const m = msgs[i]; const m = msgs[i];
const next = { ...m, toolCalls: [...(m.toolCalls || []), newCall] }; // Phase 11: Tool jetzt als Part einfuegen — landet damit chronologisch
// zwischen den Text-Parts statt oben. Legacy-toolCalls weiter pflegen
// fuer Komponenten die noch das Array lesen (ApprovalBar etc).
const next = {
...m,
parts: appendToolToParts(m.parts, newCall),
toolCalls: [...(m.toolCalls || []), newCall],
};
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)]; return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
} }
// Wenn wir auf eine User-Message stossen ohne Assistant dazwischen → neue Platzhalter-Assistant // Wenn wir auf eine User-Message stossen ohne Assistant dazwischen → neue Platzhalter-Assistant
@ -67,6 +91,7 @@ function appendToolCallToLastAssistant(toolId: string, tool: string, input: Reco
id: `m-tool-${Date.now()}`, id: `m-tool-${Date.now()}`,
role: 'assistant', role: 'assistant',
content: '', content: '',
parts: [{ type: 'tool', call: newCall }],
timestamp: new Date(), timestamp: new Date(),
toolCalls: [newCall], toolCalls: [newCall],
}; };
@ -76,21 +101,21 @@ function appendToolCallToLastAssistant(toolId: string, tool: string, input: Reco
// Aktualisiert Status + Result des passenden Tool-Calls in den Messages. // Aktualisiert Status + Result des passenden Tool-Calls in den Messages.
function finalizeInlineToolCall(toolId: string, output: string | undefined, isError: boolean) { function finalizeInlineToolCall(toolId: string, output: string | undefined, isError: boolean) {
const patch: Partial<InlineToolCall> = {
status: isError ? 'error' : 'done',
result: output,
completedAt: new Date(),
};
messages.update((msgs) => { messages.update((msgs) => {
// Suche von hinten nach der Message mit dem passenden toolId
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]; const m = msgs[i];
if (!m.toolCalls?.length) continue; const inLegacy = m.toolCalls?.some((c) => c.id === toolId) ?? false;
const idx = m.toolCalls.findIndex((c) => c.id === toolId); const inParts = m.parts?.some((p) => p.type === 'tool' && p.call.id === toolId) ?? false;
if (idx === -1) continue; if (!inLegacy && !inParts) continue;
const updatedCalls = [...m.toolCalls];
updatedCalls[idx] = { const updatedCalls = m.toolCalls?.map((c) => (c.id === toolId ? { ...c, ...patch } : c));
...updatedCalls[idx], const updatedParts = updateToolInParts(m.parts, toolId, patch);
status: isError ? 'error' : 'done', const next = { ...m, toolCalls: updatedCalls, parts: updatedParts };
result: output,
completedAt: new Date(),
};
const next = { ...m, toolCalls: updatedCalls };
return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)]; return [...msgs.slice(0, i), next, ...msgs.slice(i + 1)];
} }
return msgs; return msgs;
@ -298,6 +323,7 @@ export async function initEventListeners(): Promise<void> {
id: streamingMessageId!, id: streamingMessageId!,
role: 'assistant', role: 'assistant',
content: '', content: '',
parts: [],
timestamp: new Date(), timestamp: new Date(),
agentId: id agentId: id
} }
@ -503,7 +529,14 @@ export async function initEventListeners(): Promise<void> {
messages.update((msgs) => messages.update((msgs) =>
msgs.map((m) => msgs.map((m) =>
m.id === streamingMessageId m.id === streamingMessageId
? { ...m, content: m.content + text } ? {
...m,
content: m.content + text,
// Phase 11: parallel zu content auch parts pflegen.
// Letzten text-part erweitern oder neuen anhaengen — landet
// damit nach allen Tools die seitdem reingekommen sind.
parts: appendTextToParts(m.parts, text),
}
: m : m
) )
); );
@ -528,7 +561,15 @@ export async function initEventListeners(): Promise<void> {
if (m.id === streamingMessageId) { if (m.id === streamingMessageId) {
// Fallback: wenn kein Streaming-Text kam, result.text nutzen // Fallback: wenn kein Streaming-Text kam, result.text nutzen
const content = m.content && m.content.trim() ? m.content : (text || ''); const content = m.content && m.content.trim() ? m.content : (text || '');
finalMessage = { ...m, content, model: model || m.model }; // Wenn der Fallback gegriffen hat (Streaming kam nicht durch),
// auch parts mit dem Result-Text fuellen damit der Renderer
// etwas anzuzeigen hat.
const needsPartsFallback =
(!m.parts || m.parts.length === 0) && content;
const parts = needsPartsFallback
? appendTextToParts(m.parts, content)
: m.parts;
finalMessage = { ...m, content, parts, model: model || m.model };
return finalMessage; return finalMessage;
} }
return m; return m;
@ -665,6 +706,21 @@ export async function initEventListeners(): Promise<void> {
filePath, filePath,
addedLines: contentAfter.split('\n').length - contentBefore.split('\n').length, addedLines: contentAfter.split('\n').length - contentBefore.split('\n').length,
}); });
// Phase 11: Wenn das Praesentations-Fenster offen ist, die Aenderung
// als file-edit-Slide nachspielen. Wir versuchen es immer — wenn das
// Fenster zu ist, ignoriert das Backend die Slide.
invoke('presentation_send_slide_if_open', {
slide: {
type: 'file-edit',
content: '',
title: filePath.split('/').pop() || filePath,
language: extractLanguageFromPath(filePath),
file_path: filePath,
before: contentBefore,
after: contentAfter,
},
}).catch(() => { /* Fenster zu — egal */ });
}) })
); );

View file

@ -3,25 +3,46 @@
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import MermaidDiagram from '$lib/components/MermaidDiagram.svelte'; import MermaidDiagram from '$lib/components/MermaidDiagram.svelte';
import AnimatedCode from '$lib/components/AnimatedCode.svelte'; import AnimatedCode from '$lib/components/AnimatedCode.svelte';
import AnimatedFileEdit from '$lib/components/AnimatedFileEdit.svelte';
interface Slide { interface Slide {
type: 'mermaid' | 'code' | 'text'; type: 'mermaid' | 'code' | 'text' | 'file-edit';
content: string; content: string;
language?: string; language?: string;
title?: string; title?: string;
// file-edit-Felder
file_path?: string;
before?: string;
after?: string;
} }
// Phase 11: 5-Stufen-Speed-Regler.
// 1 = Lehrer (sehr langsam, alles lesbar)
// 2 = Lesen
// 3 = Schnell
// 4 = Profi
// 5 = Data (instant, nur Cursor-Sprung + Glow-Flash)
const SPEED_STEPS = [
{ stufe: 1, label: 'Lehrer', cps: 10, wpm: 120, beschreibung: 'jedes Zeichen sichtbar' },
{ stufe: 2, label: 'Lesen', cps: 30, wpm: 240, beschreibung: 'normales Mitlesen' },
{ stufe: 3, label: 'Schnell', cps: 120, wpm: 800, beschreibung: 'wischt zuegig durch' },
{ stufe: 4, label: 'Profi', cps: 400, wpm: 2400, beschreibung: 'kaum noch zu folgen' },
{ stufe: 5, label: 'Data', cps: 0, wpm: 9999, beschreibung: 'Cursor-Sprung + Glow' },
];
let slides = $state<Slide[]>([ let slides = $state<Slide[]>([
{ {
type: 'text', type: 'text',
title: 'Willkommen', title: 'Willkommen',
content: '🎓 Schulungsmodus bereit.\n\nClaude schickt dir hier Mindmaps, Flowcharts und animierten Code.' content: '🎓 Schulungsmodus bereit.\n\nClaude schickt dir hier Mindmaps, Flowcharts und animierten Code.\nAuch Datei-Aenderungen werden hier in Echtzeit nachgespielt.'
} }
]); ]);
let currentIndex = $state(0); let currentIndex = $state(0);
let wpm = $state(180); let speedStep = $state(2); // 1..5 (Default: Lesen)
let paused = $state(false); let paused = $state(false);
let liveCatchup = $state(true); // Folgt automatisch der neuesten Slide
const speed = $derived(SPEED_STEPS[speedStep - 1]);
const current = $derived(slides[currentIndex]); const current = $derived(slides[currentIndex]);
function next() { function next() {
@ -30,11 +51,24 @@
function prev() { function prev() {
if (currentIndex > 0) currentIndex--; if (currentIndex > 0) currentIndex--;
liveCatchup = false;
}
function jumpToLatest() {
currentIndex = slides.length - 1;
liveCatchup = true;
} }
function addSlide(slide: Slide) { function addSlide(slide: Slide) {
slides = [...slides, slide]; slides = [...slides, slide];
currentIndex = slides.length - 1; if (liveCatchup) currentIndex = slides.length - 1;
}
function handleSlideDone() {
// Nach Abschluss einer Slide automatisch zur naechsten — wenn vorhanden.
if (liveCatchup && currentIndex < slides.length - 1) {
next();
}
} }
onMount(() => { onMount(() => {
@ -50,6 +84,7 @@
if (e.key === 'ArrowRight' || e.key === ' ') next(); if (e.key === 'ArrowRight' || e.key === ' ') next();
else if (e.key === 'ArrowLeft') prev(); else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'p') paused = !paused; else if (e.key === 'p') paused = !paused;
else if (e.key >= '1' && e.key <= '5') speedStep = parseInt(e.key, 10);
}; };
window.addEventListener('keydown', keyHandler); window.addEventListener('keydown', keyHandler);
@ -64,19 +99,33 @@
<div class="presentation"> <div class="presentation">
<div class="content"> <div class="content">
{#if current} {#if current}
{#if current.title} {#if current.title && current.type !== 'file-edit'}
<h1>{current.title}</h1> <h1>{current.title}</h1>
{/if} {/if}
{#if current.type === 'mermaid'} {#if current.type === 'mermaid'}
<MermaidDiagram code={current.content} /> <MermaidDiagram code={current.content} />
{:else if current.type === 'code'} {:else if current.type === 'code'}
<AnimatedCode {#key currentIndex + ':' + speedStep}
code={current.content} <AnimatedCode
language={current.language ?? 'text'} code={current.content}
{wpm} language={current.language ?? 'text'}
autoStart={!paused} wpm={speed.wpm}
/> autoStart={!paused}
/>
{/key}
{:else if current.type === 'file-edit'}
{#key currentIndex + ':' + speedStep}
<AnimatedFileEdit
filePath={current.file_path ?? current.title ?? '?'}
before={current.before ?? ''}
after={current.after ?? ''}
language={current.language ?? 'text'}
charsPerSecond={speed.cps}
{paused}
onDone={handleSlideDone}
/>
{/key}
{:else} {:else}
<pre class="text-slide">{current.content}</pre> <pre class="text-slide">{current.content}</pre>
{/if} {/if}
@ -84,14 +133,34 @@
</div> </div>
<footer class="controls"> <footer class="controls">
<button onclick={prev} disabled={currentIndex === 0}>◀◀</button> <button onclick={prev} disabled={currentIndex === 0} title="Zurueck (Pfeil links)">◀◀</button>
<button onclick={() => paused = !paused}>{paused ? '▶' : '⏸'}</button> <button onclick={() => paused = !paused} title="Pause/Weiter (P)">{paused ? '▶' : '⏸'}</button>
<button onclick={next} disabled={currentIndex >= slides.length - 1}>▶▶</button> <button onclick={next} disabled={currentIndex >= slides.length - 1} title="Weiter (Pfeil rechts / Leertaste)">▶▶</button>
<label>
Tempo: <div class="speed-picker" role="radiogroup" aria-label="Geschwindigkeit">
<input type="range" min="60" max="400" bind:value={wpm} /> {#each SPEED_STEPS as step (step.stufe)}
<span>{wpm} WPM</span> <button
</label> class="speed-btn"
class:active={speedStep === step.stufe}
class:data-mode={step.stufe === 5 && speedStep === 5}
onclick={() => speedStep = step.stufe}
title="{step.label}{step.beschreibung}"
>
<span class="speed-num">{step.stufe}</span>
<span class="speed-label">{step.label}</span>
</button>
{/each}
</div>
<button
class="catchup-btn"
class:on={liveCatchup}
onclick={jumpToLatest}
title="Springt automatisch zur neuesten Slide"
>
{liveCatchup ? '🔴 Live' : '⏭ Live'}
</button>
<span class="counter">{currentIndex + 1} / {slides.length}</span> <span class="counter">{currentIndex + 1} / {slides.length}</span>
</footer> </footer>
</div> </div>
@ -165,15 +234,69 @@
background: #475569; background: #475569;
} }
.controls label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.counter { .counter {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: #94a3b8; color: #94a3b8;
margin-left: auto;
}
.speed-picker {
display: flex;
gap: 4px;
padding: 4px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
}
.speed-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
color: #94a3b8;
font-size: 0.72rem;
line-height: 1.1;
cursor: pointer;
transition: all 0.15s ease;
}
.speed-btn:hover { background: #1e293b; color: #e2e8f0; }
.speed-btn .speed-num { font-weight: 700; font-size: 0.85rem; color: #e2e8f0; }
.speed-btn .speed-label { font-size: 0.7rem; }
.speed-btn.active {
background: rgba(96, 165, 250, 0.15);
border-color: #60a5fa;
color: #93c5fd;
}
.speed-btn.active .speed-num { color: #ffffff; }
/* Stufe 5 (Data) bekommt Sci-Fi-Glow */
.speed-btn.data-mode {
background: rgba(168, 85, 247, 0.18);
border-color: #a855f7;
color: #e9d5ff;
animation: data-glow 1.4s ease-in-out infinite;
}
@keyframes data-glow {
0%, 100% { box-shadow: 0 0 8px rgba(168, 85, 247, 0.4); }
50% { box-shadow: 0 0 16px rgba(168, 85, 247, 0.8), inset 0 0 6px rgba(168, 85, 247, 0.4); }
}
.catchup-btn {
padding: 0.4rem 0.8rem;
background: #334155;
border: 1px solid #475569;
color: #e2e8f0;
font-size: 0.78rem;
border-radius: 4px;
cursor: pointer;
}
.catchup-btn.on {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
color: #fecaca;
} }
</style> </style>