push architecture slice 6: chat store announces list-shape writes; sidebar subscribes (chats-changed + all-chat turn events, debounced) — the 2.5s sidebar poll dies, the last standing poll

This commit is contained in:
amosblomqvist
2026-10-02 17:38:48 +03:00
parent d6450e260a
commit 8795345c82
3 changed files with 135 additions and 10 deletions
+43 -9
View File
@@ -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<ReturnType<typeof setTimeout> | 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 (
+15 -1
View File
@@ -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
+77
View File
@@ -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);
});
});