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:
+43
-9
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user