claude-desktop/src/lib/components/MessageList.svelte
Eddy f80f37884c
Some checks failed
Build AppImage / build (push) Has been cancelled
fix: Drawer bleibt offen + Auto-Scroll MutationObserver + KB-Hints Relevanz [appimage]
- Drawer: Backdrop entfernt, Panel bleibt fest offen bis X/Esc/Toggle
- Auto-Scroll: MutationObserver statt ResizeObserver (feuert bei jeder
  DOM-Aenderung), $effect scrollt nach tick() statt sofort
- KB-Hints: 60+ generische Stoppwoerter ergaenzt (bleibt, offen, rechts...),
  Relevanz-Schwelle 1.5 filtert zu generische Treffer raus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 22:22:10 +02:00

254 lines
7.9 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.2: MutationObserver statt ResizeObserver — feuert bei jeder
// DOM-Aenderung (neue Messages, WorkingIndicator, Tool-Cards, Markdown).
// $effect-Tracker scrollt nach tick() damit DOM schon gerendert ist.
import { messages, isProcessing, type Message as ChatMessage } from '$lib/stores';
import MessageItem from './Message.svelte';
import WorkingIndicator from './WorkingIndicator.svelte';
import { onMount, onDestroy, tick } from 'svelte';
interface Props {
sessionId?: string | null;
streamingMessageId?: string | null;
onEdit?: (id: string) => void;
onRegenerate?: (id: string) => void;
onRemember?: (m: ChatMessage) => void;
onRewind?: (id: string) => void;
}
let {
sessionId = null,
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 mutationObs: MutationObserver | 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: Guard sofort nach einem rAF loesen.
// Zwei rAFs waren zu langsam bei schnellem Streaming — der naechste
// Token kam bevor der Guard aufgehoben war.
requestAnimationFrame(releaseAutoScroll);
}
}
function snapToBottom() {
userScrolledUp = false;
scrollToBottom(true);
}
// Reactive-Tracker: deckt Messages, Tool-Calls, Parts und Processing ab.
// Nach tick() scrollen, damit DOM-Aenderungen (WorkingIndicator, neue
// Messages) schon gerendert sind bevor scrollHeight gelesen wird.
$effect(() => {
const last = $messages[$messages.length - 1];
const _trackers = [
$messages.length,
$isProcessing,
last?.content?.length ?? 0,
last?.parts?.length ?? 0,
(last?.parts && last.parts.length > 0)
? last.parts[last.parts.length - 1]?.content?.length ?? 0
: 0,
last?.toolCalls?.length ?? 0,
last?.toolCalls?.map((t) => t.status).join(',') ?? '',
];
void _trackers;
tick().then(() => 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) {
// Beim Rollenwechsel (user→assistant oder assistant→user):
// Immer wieder ans Ende kleben. Ohne das bleibt userScrolledUp=true
// wenn checkScroll zwischen User-Send und Assistant-Antwort feuert,
// und der Auto-Scroll fuer die gesamte Antwort ist tot.
userScrolledUp = false;
requestAnimationFrame(() => scrollToBottom(true));
lastRole = role;
}
});
// Session-Wechsel: Scroll-State zuruecksetzen damit neue Session
// immer am Ende angezeigt wird (nicht vom alten userScrolledUp blockiert)
$effect(() => {
if (sessionId) {
userScrolledUp = false;
lastRole = null;
requestAnimationFrame(() => scrollToBottom(true));
}
});
onMount(() => {
// MutationObserver: feuert bei JEDER DOM-Aenderung im Container —
// neue Messages, WorkingIndicator ein/aus, Tool-Card-Expansion,
// Markdown-Rendering, Diff-Aufklappen. Robuster als ResizeObserver
// (Container-Groesse ist bei overflow:auto konstant, nur scrollHeight
// waechst — ResizeObserver feuert da nicht).
if (container) {
mutationObs = new MutationObserver(() => {
if (!userScrolledUp) scrollToBottom();
});
mutationObs.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
}
});
onDestroy(() => {
if (mutationObs) {
mutationObs.disconnect();
mutationObs = 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>