summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte34
-rw-r--r--src/app/store.svelte.ts32
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();