claude-desktop/src/lib/components/MessageList.svelte
Eddy 71ab5ec830
All checks were successful
Build AppImage / build (push) Successful in 8m18s
feat: Schulungsmodus Datei-Animationen + Permission-Toggle + Chat-Scroll-Fix [appimage]
- 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>
2026-04-27 22:13:52 +02:00

248 lines
7.8 KiB
Svelte

<script lang="ts">
// Scrollbarer Container fuer alle Messages.
// Smart-Sticky-Scroll: Wenn der User selbst gescrollt hat, springt das
// Auto-Scroll nicht mehr zum Ende. Stattdessen erscheint ein
// Back-to-Bottom-Button.
//
// Phase 9.1: ResizeObserver am Container — feuert auch bei
// Tool-Card-Slide-In, Diff-Aufklappen, Markdown-Code-Blocks. Tracker
// liest jetzt zusaetzlich die Anzahl Tool-Calls.
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
import MessageItem from './Message.svelte';
import WorkingIndicator from './WorkingIndicator.svelte';
import { onMount, onDestroy } from 'svelte';
interface Props {
streamingMessageId?: string | null;
onEdit?: (id: string) => void;
onRegenerate?: (id: string) => void;
onRemember?: (m: ChatMessage) => void;
onRewind?: (id: string) => void;
}
let {
streamingMessageId = null,
onEdit,
onRegenerate,
onRemember,
onRewind,
}: Props = $props();
let container: HTMLDivElement | null = null;
let userScrolledUp = $state(false);
let autoScrolling = false; // Guard: ignoriert onscroll waehrend wir selbst scrollen
let autoScrollTimer: ReturnType<typeof setTimeout> | null = null;
let resizeObs: ResizeObserver | null = null;
// Working-Indicator: zeigen wenn Claude verarbeitet, aber noch keine
// Assistant-Tokens da sind (vor erstem Stream + waehrend Tool-Calls).
const showWorking = $derived.by(() => {
if (!$isProcessing) return false;
const last = $messages[$messages.length - 1];
if (!last) return true;
if (last.role !== 'assistant') return true;
return !last.content?.trim();
});
function checkScroll() {
if (!container || autoScrolling) return;
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
// 60 px Threshold — kleiner als vorher (100), damit der User schneller
// wieder "drangeklebt" wird wenn er nur kurz zuruecksteht.
const next = distance > 60;
if (next !== userScrolledUp) userScrolledUp = next;
}
function releaseAutoScroll() {
if (autoScrollTimer) {
clearTimeout(autoScrollTimer);
autoScrollTimer = null;
}
container?.removeEventListener('scrollend', releaseAutoScroll);
autoScrolling = false;
}
function scrollToBottom(force = false) {
if (!container) return;
if (!force && userScrolledUp) return;
// Vorigen Lock aufraeumen, sonst stapeln sich Timer/Listener
releaseAutoScroll();
autoScrolling = true;
// Smooth nur bei kleinen Distanzen — bei grossem Stream-Catch-up wuerde
// das die Anzeige ausbremsen, also dort instant.
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
const useSmooth = distance < 240 && !force;
if (useSmooth) {
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 {
container.scrollTop = container.scrollHeight;
// Instant-Scroll: ein rAF reicht damit das onscroll-Event durch ist
requestAnimationFrame(() => {
requestAnimationFrame(releaseAutoScroll);
});
}
}
function snapToBottom() {
userScrolledUp = false;
scrollToBottom(true);
}
// Reactive-Tracker: deckt jetzt auch Tool-Calls ab. Sobald sich die
// Anzahl Tool-Calls in der letzten Message aendert (Slide-In, Status
// running→done), wird Auto-Scroll getriggert.
$effect(() => {
const last = $messages[$messages.length - 1];
const _trackers = [
$messages.length,
$isProcessing,
last?.content?.length ?? 0,
last?.toolCalls?.length ?? 0,
last?.toolCalls?.map((t) => t.status).join(',') ?? '',
];
void _trackers;
scrollToBottom();
});
// Wenn der User eine neue Message schreibt und die Antwort ankommt, soll
// Auto-Scroll wieder anspringen — auch wenn der User vorher hochgescrollt
// war. Reset bei Wechsel von "letzter ist user" → "letzter ist assistant".
let lastRole: string | null = null;
$effect(() => {
const last = $messages[$messages.length - 1];
const role = last?.role ?? null;
if (role && role !== lastRole) {
if (role === 'user') {
// 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;
requestAnimationFrame(() => scrollToBottom(true));
}
lastRole = role;
}
});
onMount(() => {
// ResizeObserver fuer den Container: feuert wenn sich die Hoehe
// aendert (Tool-Card aufklappen, Diff-Erweitern, Code-Block rendern).
// Ohne das wuerde der Stream "abgehaengt" weil $effect nur bei
// Content-Length-Aenderung greift.
if (container && typeof ResizeObserver !== 'undefined') {
resizeObs = new ResizeObserver(() => {
if (!userScrolledUp) scrollToBottom();
});
// Den letzten Child beobachten, nicht den Container selbst —
// Container-Groesse ist konstant, sein Inhalt waechst.
const inner = container.firstElementChild;
if (inner) {
// Alle direkten Kinder beobachten ist zu teuer. Ein Wrapper
// reicht — der Container hat als Direct-Child die Message-
// Liste, und sein scrollHeight aendert sich passend.
// Wir beobachten den Container selbst — ResizeObserver
// feuert auch wenn der Inhalt waechst (clientHeight stays,
// scrollHeight grows → checkScroll triggert).
}
// Robusteste Variante: Container observed, plus Mutation-Observer
// fuer Content-Aenderungen.
resizeObs.observe(container);
}
});
onDestroy(() => {
if (resizeObs) {
resizeObs.disconnect();
resizeObs = null;
}
releaseAutoScroll();
});
</script>
<div class="message-list" bind:this={container} onscroll={checkScroll}>
{#each $messages as msg, i (msg.id)}
<MessageItem
message={msg}
isLast={i === $messages.length - 1}
isStreaming={msg.id === streamingMessageId}
{onEdit}
{onRegenerate}
{onRemember}
{onRewind}
/>
{/each}
{#if showWorking}
<WorkingIndicator />
{/if}
{#if $messages.length === 0}
<div class="empty">
<p class="empty-title">✱ Claude Code</p>
<p class="empty-hint">Stelle eine Frage, lass Code analysieren, oder tippe <code>/</code> fuer Befehle.</p>
</div>
{/if}
</div>
{#if userScrolledUp}
<button class="scroll-bottom" onclick={snapToBottom} title="Zum Ende scrollen">
↓ Neue Nachrichten
</button>
{/if}
<style>
.message-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: var(--vscode-editor-background);
padding: 4px 0;
scroll-behavior: auto; /* smooth wird per JS gesteuert */
}
.empty {
padding: 60px 24px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.empty-title {
font-size: 18px;
color: var(--vscode-button-background);
margin-bottom: 6px;
}
.empty-hint {
font-size: 13px;
}
.empty code {
background: var(--vscode-input-background);
padding: 1px 5px;
border-radius: 2px;
font-family: var(--font-mono);
}
.scroll-bottom {
position: absolute;
right: 16px;
bottom: 90px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
font-size: 11.5px;
padding: 5px 10px;
border-radius: 12px;
box-shadow: 0 2px 8px var(--vscode-widget-shadow);
z-index: 5;
animation: bounce-in 220ms ease-out;
}
.scroll-bottom:hover { background: var(--vscode-button-hoverBackground); }
@keyframes bounce-in {
from { opacity: 0; transform: translateY(8px) scale(0.9); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
</style>