diff options
| author | Adam Malczewski <[email protected]> | 2026-05-29 13:59:17 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-29 13:59:17 +0900 |
| commit | 520c9e30cc58b40d3b1ee9e7895f003c4f873206 (patch) | |
| tree | 7a10da94141d754dca25e728f4a388057a5d5981 /packages/frontend/src/lib/components | |
| parent | 0954c6520878e073c7822fc4a8f4a752474f5d7a (diff) | |
| download | dispatch-520c9e30cc58b40d3b1ee9e7895f003c4f873206.tar.gz dispatch-520c9e30cc58b40d3b1ee9e7895f003c4f873206.zip | |
feat: disappearing chat history — chunk-limited frontend window with backend pagination
Frontend keeps only a bounded window of chunks in memory (configurable via
settings slider, default 100). Older messages are evicted when at the bottom
and re-fetched from the backend on scroll-up.
- Backend: paginated GET /tabs/:id/messages with ?limit=N&before=seq
- Store: evictMessages trims oldest messages until total chunks ≤ limit
- Store: loadMoreMessages fetches next page and prepends with dedup
- ChatPanel: smart scroll hooks trigger eviction on return-to-bottom
- ChatPanel: onNearTop loads older history with scroll-position maintenance
- Settings: chunk limit slider in Memory section
- Fix: oldestLoadedSeq recalculated after eviction (pagination cursor stays valid)
- Fix: seq preserved on ChatMessage for cursor tracking
- Fix: scrolledUpTabs cleaned up on tab switch (no memory leak)
- Fix: evictMessages reads appSettings.chunkLimit directly (live updates)
Diffstat (limited to 'packages/frontend/src/lib/components')
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 68 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 29 |
2 files changed, 96 insertions, 1 deletions
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte index 82bd4ee..8170d43 100644 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ b/packages/frontend/src/lib/components/ChatPanel.svelte @@ -1,11 +1,12 @@ <script lang="ts"> -import { untrack } from "svelte"; +import { tick, untrack } from "svelte"; import { tabStore } from "../tabs.svelte.js"; import ChatMessageComponent from "./ChatMessage.svelte"; let messagesEl: HTMLDivElement | undefined; let userScrolledUp = $state(false); let isAutoScrolling = false; +let isLoadingMore = $state(false); const messages = $derived(tabStore.activeTab?.messages ?? []); const activeTabId = $derived(tabStore.activeTab?.id); @@ -22,14 +23,64 @@ function scrollToBottom(animate = false) { }); } +async function onNearTop() { + if (isLoadingMore) return; + const tab = tabStore.activeTab; + if (!tab) return; + // Nothing older to load if we're already at the very first message, or if + // we already hold every message the backend has for this tab. + if (tab.oldestLoadedSeq !== null && tab.oldestLoadedSeq <= 0) return; + + isLoadingMore = true; + const prevScrollHeight = messagesEl?.scrollHeight ?? 0; + const prevScrollTop = messagesEl?.scrollTop ?? 0; + try { + await tabStore.loadMoreMessages(tab.id); + // Wait for Svelte to flush the prepended messages into the DOM. + // Reading `scrollHeight` synchronously after the await would observe + // the OLD layout (reactive updates are batched), so the scroll + // correction would be computed against a stale height and the + // viewport would jump. `tick()` resolves once the DOM reflects the + // new message list. + await tick(); + if (messagesEl) { + const newScrollHeight = messagesEl.scrollHeight; + const delta = newScrollHeight - prevScrollHeight; + // Only adjust when content was actually prepended above the + // viewport. If nothing was added (all duplicates / nothing older), + // `delta` is 0 and we leave the user where they are instead of + // snapping to the top. + if (delta > 0) { + messagesEl.scrollTop = prevScrollTop + delta; + } + } + } finally { + isLoadingMore = false; + } +} + function handleScroll() { if (!messagesEl || isAutoScrolling) return; + const wasScrolledUp = userScrolledUp; userScrolledUp = !isNearBottom(messagesEl); + if (activeTabId) tabStore.setScrolledUp(activeTabId, userScrolledUp); + // User just scrolled back to the bottom manually — safe to evict now. + if (wasScrolledUp && !userScrolledUp && activeTabId) { + tabStore.evictMessages(activeTabId); + } + // Near the top — pull in older history. + if (userScrolledUp && messagesEl.scrollTop < 200) { + void onNearTop(); + } } function resumeAutoScroll() { userScrolledUp = false; isAutoScrolling = true; + if (activeTabId) { + tabStore.setScrolledUp(activeTabId, false); + tabStore.evictMessages(activeTabId); + } scrollToBottom(true); } @@ -42,6 +93,18 @@ $effect(() => { }); } }); + +$effect(() => { + const prevTabId = activeTabId; + // Reset scroll state when switching tabs + userScrolledUp = false; + isAutoScrolling = false; + return () => { + if (prevTabId) { + tabStore.setScrolledUp(prevTabId, false); + } + }; +}); </script> <div class="flex flex-col h-full"> @@ -55,6 +118,9 @@ $effect(() => { isAutoScrolling = false; }} > + {#if isLoadingMore} + <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div> + {/if} {#if messages.length === 0} <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> Send a message to start a conversation diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index eadfef8..392852a 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -16,9 +16,19 @@ let titleModelId = $state<string | null>(null); let availableModels = $state<string[]>([]); let loadingModels = $state(false); let autoExpandThinking = $state(appSettings.autoExpandThinking); +let localChunkLimit = $state(appSettings.chunkLimit); let backendUrl = $state(config.apiBase); let backendUrlSaved = $state(false); +function onChunkLimitChange(e: Event): void { + const input = e.target as HTMLInputElement; + const val = parseInt(input.value, 10); + if (val >= 10 && val <= 2000) { + appSettings.chunkLimit = val; + localChunkLimit = val; + } +} + function saveBackendUrl(): void { const trimmed = backendUrl.trim().replace(/\/+$/, ""); if (!trimmed) return; @@ -169,6 +179,25 @@ $effect(() => { <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Memory</p> + <label class="flex flex-col gap-1"> + <span class="text-xs text-base-content/70"> + Max chunks in memory: <span class="font-semibold">{localChunkLimit}</span> + </span> + <input + type="range" + min="20" + max="1000" + step="10" + class="range range-xs" + value={localChunkLimit} + oninput={onChunkLimitChange} + /> + <span class="text-[10px] text-base-content/40">Lower = less RAM. Higher = less re-fetching.</span> + </label> + + <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Backend URL</p> <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p> <div class="flex gap-1"> |
