diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 18:26:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 18:26:00 +0900 |
| commit | 1764e3e5dff836255d121a933dd92542368346f9 (patch) | |
| tree | b835055de0f0f1fd9750741764dac8b30f7498bf /src/app | |
| parent | 4001274e3ba25a3946df1e9f2dc82ca6781cd2bf (diff) | |
| download | dispatch-web-1764e3e5dff836255d121a933dd92542368346f9.tar.gz dispatch-web-1764e3e5dff836255d121a933dd92542368346f9.zip | |
feat(chat): chat limit — bulk quarter-unload, 75% fresh-load window, show-earlier page-in
Long transcripts no longer grow unbounded: past the chat limit (default 256
chunks, localStorage dispatch.chatLimit) the oldest ceil(limit/4) committed
chunks are unloaded in ONE bulk pass — never one-per-delta (old Dispatch's
scroll-jump-per-step bug) — and only while the reader is stuck to the bottom
(scrolled-up readers defer the trim; it catches up in whole quarters). A fresh
page load windows to the newest floor(0.75*limit). Unloading is purely local
(IndexedDB cache + server keep everything); a hiddenBeforeSeq watermark keeps
history merges from resurrecting unloaded chunks, and a 'Show earlier messages'
affordance pages a quarter back in from the cache with scroll-anchor
preservation. Thinking-collapse render keys stay stable across trims via a
hiddenThinkingCount ordinal base.
- core/chunks/trim.ts: pure policy (trim/window/restore/normalize) + tests
- chat store: chatLimit + canUnload deps, windowed load, showEarlier()
- composition root: dispatch.chatLimit localStorage knob + unload gate wired
to smart-scroll isAtBottom()
- backend CR-5 OPENED (not a blocker): ?limit=/?beforeSeq= on
GET /conversations/:id (courier backend-handoff-chat-limit.md)
- scripts/live-probe.ts: fix pre-existing stale TurnMetricsEntry reads
(m1.usage -> total.usage) that crashed the probe; 17/17 live checks pass
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 34 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 32 |
2 files changed, 65 insertions, 1 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 50f24e7..4c5a82b 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; + import { tick } from "svelte"; import Table from "../components/Table.svelte"; import { CacheWarmingView, @@ -76,6 +77,31 @@ let transcriptEl = $state<HTMLElement | undefined>(); let transcriptContentEl = $state<HTMLElement | undefined>(); + // Chat-limit unload gate: old chunks may be unloaded only while the reader is + // stuck to the bottom. While stuck, a trim removes content far ABOVE the + // viewport and the controller re-pins to the bottom — no visible jump; while + // reading history, trimming is deferred instead of yanking the page (the old + // Dispatch bug). In an $effect so a swapped store prop would be re-wired. + $effect(() => { + store.attachUnloadGate(() => smartScroll.isAtBottom()); + }); + + // "Show earlier messages": page older history back in, preserving the reader's + // viewport position — prepended content grows scrollHeight, so shift scrollTop + // by the growth (the manual analogue of CSS scroll anchoring, which not every + // engine applies here). + async function handleShowEarlier(): Promise<void> { + const el = transcriptEl; + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + await store.activeChat.showEarlier(); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + } + // Attach/detach the controller to the live scroll element + content (disposed on // unmount). The content element is observed (ResizeObserver) so the view follows // height changes that aren't a transcript append. @@ -201,7 +227,13 @@ <div bind:this={transcriptEl} class="h-full overflow-y-auto"> <div bind:this={transcriptContentEl}> {#key store.activeConversationId} - <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> + <ChatView + chunks={store.activeChat.chunks} + turnMetrics={store.activeChat.turnMetrics} + hasEarlier={store.activeChat.hasEarlier} + onShowEarlier={handleShowEarlier} + thinkingKeyBase={store.activeChat.thinkingKeyBase} + /> {/key} </div> </div> diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 2837bb5..379805f 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -15,6 +15,7 @@ import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; +import { normalizeChatLimit } from "../core/chunks"; import { applyServerMessage, getSurfaceSpec, @@ -88,6 +89,15 @@ export interface AppStore { * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. */ lspStatus(): Promise<LspResult | null>; + /** + * Wire the chat-limit unload gate (composition-root injection, called once by + * the shell after it owns the scroll region): unloading old chunks is allowed + * only while the gate returns true — i.e. the reader is stuck to the bottom — + * so a trim never yanks content out from under someone reading history. + * Before attachment unloading is allowed (the initial view starts at the + * bottom). + */ + attachUnloadGate(gate: () => boolean): void; dispose(): void; } @@ -157,6 +167,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }); const tabsStore: TabsStore = createTabsStore(storageAdapter); + // The chat limit (max loaded chunks per conversation) — a persisted local + // setting with no UI yet: edit `localStorage["dispatch.chatLimit"]`. The + // default is written back on first run so the knob is discoverable. + const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", { + storage: localStorageOpt, + }); + const storedChatLimit = chatLimitStore.load(); + const chatLimit = normalizeChatLimit(storedChatLimit); + if (storedChatLimit === null) { + chatLimitStore.save(chatLimit); + } + + // Unload gate — attached by the shell once it owns the scroll region (see + // `AppStore.attachUnloadGate`). Until then, unloading is allowed. + let unloadGate: (() => boolean) | null = null; + const cache: ConversationCache = createConversationCache( createIdbChunkStore({ indexedDB: indexedDBFactory }), ); @@ -178,6 +204,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { historySync, metricsSync, cache, + chatLimit, + canUnload: () => (unloadGate === null ? true : unloadGate()), }); } @@ -607,6 +635,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }; } }, + attachUnloadGate(gate: () => boolean): void { + unloadGate = gate; + }, + dispose(): void { for (const store of chatStores.values()) { store.dispose(); |
