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>
248 lines
7.8 KiB
Svelte
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>
|