fix: correct autoscroll race conditions and lazy load trigger

- Move isRestoringScroll = true before await tick() so scroll events
  fired during the DOM transition cannot incorrectly set shouldAutoScroll
  to false, breaking autoscroll for all tabs except the first
- Guard the windowStart reactive block with isSwitchingConversation so
  stale lines from the previous conversation cannot override the correct
  windowStart after a tab switch
- Fix lazy load threshold to trigger relative to the visible window top
  (windowStart * AVG_LINE_HEIGHT + 300) rather than the absolute scroll
  container top, so older messages load without scrolling through the spacer
This commit is contained in:
2026-03-06 22:25:31 -08:00
committed by Naomi Carrigan
parent e9a9ffc9ab
commit 80a59a0b22
+31 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte";
@@ -25,6 +26,7 @@
let isRestoringScroll = false;
let windowStart = 0;
let isLoadingMore = false;
let isSwitchingConversation = false;
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
searchQuery.subscribe((value) => {
@@ -59,24 +61,39 @@
currentConversationId = newId;
// Peek at the saved position to set windowStart before the first tick,
// preventing a stale windowStart from a previous conversation leaving
// visibleLines empty (windowStart >= lines.length).
// Guard the $: reactive auto-scroll block from firing with stale `lines`
// (the old conversation's data) during the switch. Without this, Svelte's
// reactive system can re-run the window-advance block before `terminalLines`
// has recomputed for the new conversation, overriding our correct windowStart.
isSwitchingConversation = true;
// Read the new conversation's lines directly from the store — the derived
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
// this subscriber fires, so using `lines` here would give stale data.
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
const savedPosition = claudeStore.getScrollPosition(newId);
if (savedPosition === -1) {
// Will auto-scroll: pin the window to the tail of the new conversation
shouldAutoScroll = true;
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
} else {
// Will restore a specific position: always start from the top of history
shouldAutoScroll = false;
windowStart = 0;
}
// Restore scroll position for the new conversation after DOM updates
// Block the scroll handler during the entire DOM transition — scroll events
// can fire mid-tick when the content changes, and handleScroll would see
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
// autoscroll for the new conversation permanently.
isRestoringScroll = true;
// Restore scroll position for the new conversation after DOM updates.
// Clear the switch guard first so the $: block can react to new lines
// arriving after the switch settles.
await tick();
isSwitchingConversation = false;
if (terminalElement) {
isRestoringScroll = true;
if (savedPosition === -1) {
terminalElement.scrollTop = terminalElement.scrollHeight;
} else {
@@ -94,8 +111,10 @@
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
// Load older lines when the user scrolls near the top
if (scrollTop < 300 && windowStart > 0 && !isLoadingMore) {
// Load older lines when the user scrolls near the top of the visible window.
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
// we trigger at the top of the rendered content, not the absolute container top.
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
isLoadingMore = true;
const prevScrollHeight = terminalElement.scrollHeight;
const prevScrollTop = terminalElement.scrollTop;
@@ -182,8 +201,10 @@
// Height of the invisible spacer above the visible window
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
// Advance the window forward when auto-scrolling and new lines overflow it
$: if (shouldAutoScroll && lines.length > windowStart + WINDOW_SIZE) {
// Advance the window forward when auto-scrolling and new lines overflow it.
// Skip during conversation switches — `lines` may still hold the previous
// conversation's data, which would push windowStart past the new conv's end.
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
}