feat: Schulungsmodus Datei-Animationen + Permission-Toggle + Chat-Scroll-Fix [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m18s
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:
parent
79f4f9fb21
commit
71ab5ec830
12 changed files with 888 additions and 85 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
298
src/lib/components/AnimatedFileEdit.svelte
Normal file
298
src/lib/components/AnimatedFileEdit.svelte
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 */ });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue