From 4d2e6d817f65a13631f00786c45b403d7d7d58ec Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 18:26:00 +0900 Subject: feat(chat): chat limit — bulk quarter-unload, 75% fresh-load window, show-earlier page-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/chat/ui.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) (limited to 'src/features/chat/ui.test.ts') diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 278b2cf..7174821 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -41,6 +41,45 @@ describe("ChatView", () => { expect(screen.getByText("Hello!")).toBeInTheDocument(); }); + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { + const chunks: RenderedChunk[] = [ + { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, + ]; + + let resolveEarlier: (() => void) | undefined; + const onShowEarlier = vi.fn( + () => + new Promise((resolve) => { + resolveEarlier = resolve; + }), + ); + + render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); + + const button = screen.getByRole("button", { name: /show earlier messages/i }); + const user = userEvent.setup(); + await user.click(button); + + expect(onShowEarlier).toHaveBeenCalledTimes(1); + // While the page-in is awaited the button is disabled (no double-fire). + expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); + + resolveEarlier?.(); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); + }); + }); + + it("hides the show-earlier button when nothing is unloaded", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, + ]; + + render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); + + expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); + }); + it("renders tool-call chunks", () => { const chunks: RenderedChunk[] = [ { -- cgit v1.2.3