Some checks failed
Build AppImage / build (push) Has been cancelled
- 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>
254 lines
7.9 KiB
Svelte
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>
|