claude-desktop/src/lib/components/WorkingIndicator.svelte
Eddy 79f4f9fb21
All checks were successful
Build AppImage / build (push) Successful in 8m20s
fix: UTF-8-Crash + Input-Reset + ApprovalBar + Scroll/Streaming-Polish [appimage]
Crash-Fix:
- src/db.rs:801 panickte mit "byte index 240 is not a char boundary"
  mitten in einem -Emoji → SIGABRT. Neues strutil-Modul mit
  safe_truncate()/safe_truncate_ellipsis() (5 Tests grün), an allen
  &s[..N]-Stellen in db/claude/knowledge/session/memory.rs eingebaut.
- update.rs: Stale Lock-Files vom letzten Crash werden jetzt
  protokolliert ("🧹 Stale Lock-Datei aus vorherigem Crash gefunden").

Chat-Polish:
- Input-Textfeld wird nach Senden zuverlässig geleert (Store-Reset +
  DOM-Reset + tick — Svelte 5 bind:value mit Auto-Subscription
  aktualisiert sonst nicht synchron).
- ApprovalBar.svelte (NEU): Sticky-Bar überm Input mit klar
  beschrifteten Buttons "Übernehmen"/"Verwerfen" statt mehrdeutigem
  "Behalten/Zurueck". Bleibt sichtbar wenn der Chat scrollt. Klick
  auf Datei-Name scrollt zur Inline-Karte und blinkt sie. Shortcuts
  Ctrl+Enter/Ctrl+Backspace.
- MessageList: Auto-Scroll trackt jetzt auch toolCalls.length und
  Status-Änderungen, plus ResizeObserver am Container. Smooth bei
  kleinen Distanzen, instant bei großen.
- Streaming-Caret: pulsierender Block-Cursor mit Glow-Shadow.
- Tool-Cards: Slide-In-Transition + Shimmer-Animation auf running.
- WorkingIndicator: Verb passt sich an processingPhase an.
2026-04-27 20:55:08 +02:00

160 lines
4 KiB
Svelte

<script lang="ts">
// Animierter "Claude arbeitet"-Indikator unter der letzten Message.
// Rotiert ein zufaelliges deutsches Verb alle ~2.5s, daneben laeuft ein
// Braille-Spinner und ein Sekunden-Counter. Stil orientiert sich an
// Claude Code.
import { onMount, onDestroy } from 'svelte';
import { processingPhase, currentTool } from '$lib/stores/events';
// Allgemeine Verben fuer "denken/grübeln" — wenn keine Phase bekannt ist
const VERBS = [
'Denke nach',
'Gruebele',
'Bruete',
'Sinniere',
'Tueftle',
'Werkle',
'Stoebere',
'Knirsche',
'Bastle',
'Mische',
'Brodelt',
'Kluegele',
'Wuehle',
'Krame',
'Spinne',
'Schmiede',
'Pruefe',
'Lese',
'Verdaue',
'Sortiere',
'Sammle',
'Suche',
'Forsche',
'Faedele ein',
'Knete',
'Falte die Stirn',
'Klopfe ab',
'Lege Hand an',
];
// Pro Phase ein passender Text — Codium-Style "claude is thinking…"
function phaseVerb(phase: string, tool: string | null): string {
switch (phase) {
case 'thinking': return 'Denkt nach';
case 'streaming': return 'Schreibt';
case 'tool-use': return tool ? `Nutzt ${tool}` : 'Nutzt Tool';
case 'subagent': return 'Subagent arbeitet';
default: return '';
}
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let randomVerb = $state(pickVerb());
let spinnerIdx = $state(0);
let elapsed = $state(0);
// Phase-bewusstes Verb: bevorzugt die spezifische Phase, sonst Random.
const verb = $derived.by(() => {
const pv = phaseVerb($processingPhase, $currentTool);
return pv || randomVerb;
});
let verbTimer: ReturnType<typeof setInterval> | null = null;
let spinTimer: ReturnType<typeof setInterval> | null = null;
let secondsTimer: ReturnType<typeof setInterval> | null = null;
let lastVerb = '';
function pickVerb(): string {
// Nicht zweimal hintereinander dasselbe Wort
let next = VERBS[Math.floor(Math.random() * VERBS.length)];
if (next === lastVerb && VERBS.length > 1) {
next = VERBS[(VERBS.indexOf(next) + 1) % VERBS.length];
}
lastVerb = next;
return next;
}
onMount(() => {
const start = Date.now();
verbTimer = setInterval(() => { randomVerb = pickVerb(); }, 2500);
spinTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
}, 90);
secondsTimer = setInterval(() => {
elapsed = Math.floor((Date.now() - start) / 1000);
}, 1000);
});
onDestroy(() => {
if (verbTimer) clearInterval(verbTimer);
if (spinTimer) clearInterval(spinTimer);
if (secondsTimer) clearInterval(secondsTimer);
});
</script>
<div class="working" role="status" aria-live="polite">
<span class="spinner">{SPINNER_FRAMES[spinnerIdx]}</span>
<span class="verb">{verb}</span>
<span class="dots"><span>.</span><span>.</span><span>.</span></span>
{#if elapsed > 1}
<span class="elapsed">({elapsed}s)</span>
{/if}
</div>
<style>
.working {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px 14px 52px;
font-size: 13px;
color: var(--vscode-descriptionForeground, #888);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
opacity: 0;
animation: fade-in 200ms ease-out forwards;
}
.spinner {
color: var(--accent, var(--vscode-button-background, #007acc));
font-size: 15px;
line-height: 1;
display: inline-block;
min-width: 14px;
text-align: center;
}
.verb {
color: var(--text-primary, var(--vscode-foreground, #ddd));
font-style: italic;
min-width: 100px;
transition: opacity 200ms ease;
}
.dots {
display: inline-flex;
gap: 2px;
color: var(--text-primary, var(--vscode-foreground, #ddd));
}
.dots span {
animation: dot-pulse 1.4s ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: 200ms; }
.dots span:nth-child(3) { animation-delay: 400ms; }
.elapsed {
color: var(--text-muted, #888);
font-size: 11px;
margin-left: 4px;
}
@keyframes fade-in {
to { opacity: 1; }
}
@keyframes dot-pulse {
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
</style>