From 8795345c8241508953fd861b09967b858218fd45 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 17:38:48 +0300 Subject: [PATCH] =?UTF-8?q?push=20architecture=20slice=206:=20chat=20store?= =?UTF-8?q?=20announces=20list-shape=20writes;=20sidebar=20subscribes=20(c?= =?UTF-8?q?hats-changed=20+=20all-chat=20turn=20events,=20debounced)=20?= =?UTF-8?q?=E2=80=94=20the=202.5s=20sidebar=20poll=20dies,=20the=20last=20?= =?UTF-8?q?standing=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/sidebar.tsx | 52 ++++++++++++++++++---- src/store/chats.ts | 16 ++++++- tests/chat-store-events.test.ts | 77 +++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/chat-store-events.test.ts diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 61dd073..e4fb933 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -17,6 +17,7 @@ import { Hint } from "@/components/ui/hint"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useServerEvent, useServerResync } from "@/components/server-events"; import type { ChatListItem } from "@/src/core"; import { kbdEventForAction } from "@/src/core"; @@ -350,14 +351,22 @@ function SessionsSidebar() { return chats.filter((c) => c.title.toLowerCase().includes(q)); }, [chats, query]); + const refreshInFlightRef = useRef(false); const refresh = useCallback(async () => { + // In-flight coalesce (S6): event bursts may land several refresh + // calls at once — one fetch serves them all; anything newer arrives + // with the next event's fetch. + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; try { const res = await fetch("/api/chats", { cache: "no-store" }); const d = (await res.json()) as { chats: ChatListItem[] }; setChats(d.chats); } catch { - // a transient failure on the periodic poll keeps the last snapshot + // a transient failure keeps the last snapshot setChats((prev) => prev ?? []); + } finally { + refreshInFlightRef.current = false; } }, []); @@ -379,7 +388,7 @@ function SessionsSidebar() { busy mirrors the server's own definition (isTurnRunning), so the patch is exact: spinner up the moment the action happens, cleared and refetched once at settle (title/order move with turns). The - 2.5s poll stays as the BACKSTOP for everything only the server + events channel is the backstop for everything only the server knows: other windows, wake turns in unopened sessions. A start on a chat not yet in the snapshot (a brand-new session) = one immediate refresh instead of a patch. */ @@ -403,6 +412,36 @@ function SessionsSidebar() { return () => window.removeEventListener("lh:turn-state", onTurnState); }, [refresh]); + /* PUSH (architecture S6, 2026-10-02): the 2.5s poll is RETIRED — the + last standing network poll in the app. List truth arrives as + events: chats-changed (create/rename/delete — the list-SHAPE + writes), and turn-started/turn-settled for EVERY chat (unfiltered: + any chat's turn flips busy/order/phase in this list). Announced + fetches ride the 250ms trailing debounce (the S4/S5 law); resync + (reconnect / dev-restart) is immediate. lh:turn-state + + lh:chat-renamed stay for same-tab INSTANTS — local beats any + round-trip. */ + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + const eventDebounceRef = useRef | undefined>(undefined); + const scheduleEventRefresh = () => { + if (eventDebounceRef.current) clearTimeout(eventDebounceRef.current); + eventDebounceRef.current = setTimeout(() => { + eventDebounceRef.current = undefined; + void refreshRef.current(); + }, 250); + }; + useServerEvent("chats-changed", scheduleEventRefresh); + useServerEvent("turn-started", scheduleEventRefresh); + useServerEvent("turn-settled", scheduleEventRefresh); + useServerResync(() => void refreshRef.current()); + useEffect( + () => () => { + if (eventDebounceRef.current) clearTimeout(eventDebounceRef.current); + }, + [], + ); + /* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns every session-list interaction, so the shortcuts land here: ⌘⇧F focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar @@ -554,13 +593,8 @@ function SessionsSidebar() { useEffect(() => { if (!open) return; void refresh(); - // Poll while open: the statuses are live (a question lands in another - // session, a turn finishes in the background) and the payload is tiny. - // Pause while the tab is hidden; navigation refetches via pathname. - const id = setInterval(() => { - if (!document.hidden) void refresh(); - }, 2500); - return () => clearInterval(id); + // Opening + navigation refetch; while open, the events channel owns + // freshness (S6) — no interval, nothing standing. }, [open, pathname, refresh]); return ( diff --git a/src/store/chats.ts b/src/store/chats.ts index f67eee4..9891959 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -10,6 +10,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { PATHS } from "../../config.ts"; +import { emit } from "../core/events.ts"; import type { ChatStore } from "../core/ports.ts"; import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts"; import type { LhMessage } from "../core/native-messages.ts"; @@ -21,6 +22,15 @@ function chatFile(id: string): string { return path.join(PATHS.chats, `${id}.json`); } +/** THE SILENCE LAW (push S6, 2026-10-02): chats-changed announces ONLY + * list-SHAPE mutations — createChat, renameChat, deleteChat (below, + * each emits post-write). The content writers (saveMessages, + * appendTurnUsage, appendExtractionUsage, saveSettings, saveTree) are + * DELIBERATELY SILENT: saveMessages fires per turn-persist and would + * spam the bus, and every list-visible change it causes (title, order, + * phase, busy, pendingQuestion) is already announced by turn-started / + * turn-settled, which the sidebar refetches on. Silence here is law, + * not oversight. */ function createChat(title: string): ChatRecord { const now = new Date().toISOString(); const record: ChatRecord = { @@ -32,6 +42,7 @@ function createChat(title: string): ChatRecord { }; fs.mkdirSync(PATHS.chats, { recursive: true }); fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8"); + emit("chats-changed", {}); return record; } @@ -86,7 +97,8 @@ function listChats(): ChatSummary[] { } /** Persist the full native message list (the record IS the agent state — - * Wave B: replay is state.messages = record.messages). */ + * Wave B: replay is state.messages = record.messages). SILENT by the + * events law — see the SILENCE LAW comment at createChat. */ function saveMessages(id: string, messages: LhMessage[]): void { const record = getChat(id); if (!record) throw new Error(`Chat not found: ${id}`); @@ -141,6 +153,7 @@ function renameChat(id: string, title: string): ChatRecord { if (!record) throw new Error(`Chat not found: ${id}`); record.title = title.trim() || "Untitled session"; fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); + emit("chats-changed", {}); return record; } @@ -151,6 +164,7 @@ function renameChat(id: string, title: string): ChatRecord { function deleteChat(id: string): boolean { try { fs.unlinkSync(chatFile(id)); + emit("chats-changed", {}); // only a real removal — the present-entry law return true; } catch { return false; // never existed, already deleted, or unreadable — same answer diff --git a/tests/chat-store-events.test.ts b/tests/chat-store-events.test.ts new file mode 100644 index 0000000..12ca1e4 --- /dev/null +++ b/tests/chat-store-events.test.ts @@ -0,0 +1,77 @@ +/** + * CHAT STORE EVENT PINS (push-architecture S6, 2026-10-02): the sidebar's + * 2.5s poll died — list truth now rides chats-changed. The store's law: + * list-SHAPE writes (create/rename/delete) announce post-write; content + * writes (messages/usage/settings/tree) are DELIBERATELY silent — the + * silence law is pinned here so it reads as law, not oversight. Runs + * under an isolated LH_DATA_DIR (the chat-birth-stamp discipline: the + * seam is set before any config-loading import, so every app-module + * import is dynamic). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// ── the seam, BEFORE any config-loading import ────────────────────────────── +const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-chat-store-events-")); +process.env.LH_DATA_DIR = TMP_DATA; + +const { chatStore } = await import("../src/store/chats.ts"); +const { subscribe } = await import("../src/core/events.ts"); + +after(() => { + fs.rmSync(TMP_DATA, { recursive: true, force: true }); +}); + +describe("chat store events (S6: the silence law)", () => { + it("createChat, renameChat, deleteChat each announce chats-changed", () => { + let announces = 0; + const off = subscribe((e) => { + if (e.type === "chats-changed") announces++; + }); + + const chat = chatStore.createChat("Store events fixture"); + assert.equal(announces, 1, "create announces post-write"); + + chatStore.renameChat(chat.id, "Renamed"); + assert.equal(announces, 2, "rename announces (a list-shape write)"); + + assert.equal(chatStore.deleteChat(chat.id), true); + assert.equal(announces, 3, "a real delete announces"); + off(); + }); + + it("deleteChat on a missing id stays silent", () => { + let announces = 0; + const off = subscribe((e) => { + if (e.type === "chats-changed") announces++; + }); + assert.equal(chatStore.deleteChat("chat_never_existed"), false); + assert.equal(announces, 0, "nothing was written — nothing announced"); + off(); + }); + + it("content writes are DELIBERATELY silent (the law)", () => { + const chat = chatStore.createChat("Silent content fixture"); + let announces = 0; + const off = subscribe((e) => { + if (e.type === "chats-changed") announces++; + }); + + // saveMessages: fires per turn-persist — would spam the bus during + // turns; its list-visible effects arrive via turn-started/settled. + chatStore.saveMessages(chat.id, [{ id: "m1", role: "user", content: "hello", timestamp: 1 } as never]); + // usage appends + settings + tree: record CONTENT, not list shape. + chatStore.appendTurnUsage(chat.id, { id: "t1", messageId: "m1", at: "2026-10-02T00:00:00.000Z", model: "x:y", steps: [] }); + chatStore.appendExtractionUsage(chat.id, { startedAt: 1, at: "2026-10-02T00:00:00.000Z", durationMs: 1, stages: { extractor: { model: "x:y", steps: [] }, auditor: { model: "x:y", steps: [] } } }); + chatStore.saveSettings(chat.id, {}); + chatStore.saveTree(chat.id, { nodes: [], edges: [], version: 1 }); + + assert.equal(announces, 0, "the silence law: content writes never announce"); + off(); + chatStore.deleteChat(chat.id); + }); +});