summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-29 13:59:17 +0900
committerAdam Malczewski <[email protected]>2026-05-29 13:59:17 +0900
commit520c9e30cc58b40d3b1ee9e7895f003c4f873206 (patch)
tree7a10da94141d754dca25e728f4a388057a5d5981 /packages/frontend/src/lib/components
parent0954c6520878e073c7822fc4a8f4a752474f5d7a (diff)
downloaddispatch-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.svelte68
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte29
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">