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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
import type { ChatListItem } from "@/src/core";
|
import type { ChatListItem } from "@/src/core";
|
||||||
import { kbdEventForAction } from "@/src/core";
|
import { kbdEventForAction } from "@/src/core";
|
||||||
|
|
||||||
@@ -350,14 +351,22 @@ function SessionsSidebar() {
|
|||||||
return chats.filter((c) => c.title.toLowerCase().includes(q));
|
return chats.filter((c) => c.title.toLowerCase().includes(q));
|
||||||
}, [chats, query]);
|
}, [chats, query]);
|
||||||
|
|
||||||
|
const refreshInFlightRef = useRef(false);
|
||||||
const refresh = useCallback(async () => {
|
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 {
|
try {
|
||||||
const res = await fetch("/api/chats", { cache: "no-store" });
|
const res = await fetch("/api/chats", { cache: "no-store" });
|
||||||
const d = (await res.json()) as { chats: ChatListItem[] };
|
const d = (await res.json()) as { chats: ChatListItem[] };
|
||||||
setChats(d.chats);
|
setChats(d.chats);
|
||||||
} catch {
|
} catch {
|
||||||
// a transient failure on the periodic poll keeps the last snapshot
|
// a transient failure keeps the last snapshot
|
||||||
setChats((prev) => prev ?? []);
|
setChats((prev) => prev ?? []);
|
||||||
|
} finally {
|
||||||
|
refreshInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -379,7 +388,7 @@ function SessionsSidebar() {
|
|||||||
busy mirrors the server's own definition (isTurnRunning), so the
|
busy mirrors the server's own definition (isTurnRunning), so the
|
||||||
patch is exact: spinner up the moment the action happens, cleared
|
patch is exact: spinner up the moment the action happens, cleared
|
||||||
and refetched once at settle (title/order move with turns). The
|
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
|
knows: other windows, wake turns in unopened sessions. A start on
|
||||||
a chat not yet in the snapshot (a brand-new session) = one
|
a chat not yet in the snapshot (a brand-new session) = one
|
||||||
immediate refresh instead of a patch. */
|
immediate refresh instead of a patch. */
|
||||||
@@ -403,6 +412,36 @@ function SessionsSidebar() {
|
|||||||
return () => window.removeEventListener("lh:turn-state", onTurnState);
|
return () => window.removeEventListener("lh:turn-state", onTurnState);
|
||||||
}, [refresh]);
|
}, [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
|
/* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns
|
||||||
every session-list interaction, so the shortcuts land here: ⌘⇧F
|
every session-list interaction, so the shortcuts land here: ⌘⇧F
|
||||||
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar
|
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar
|
||||||
@@ -554,13 +593,8 @@ function SessionsSidebar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void refresh();
|
void refresh();
|
||||||
// Poll while open: the statuses are live (a question lands in another
|
// Opening + navigation refetch; while open, the events channel owns
|
||||||
// session, a turn finishes in the background) and the payload is tiny.
|
// freshness (S6) — no interval, nothing standing.
|
||||||
// Pause while the tab is hidden; navigation refetches via pathname.
|
|
||||||
const id = setInterval(() => {
|
|
||||||
if (!document.hidden) void refresh();
|
|
||||||
}, 2500);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [open, pathname, refresh]);
|
}, [open, pathname, refresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+15
-1
@@ -10,6 +10,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { PATHS } from "../../config.ts";
|
import { PATHS } from "../../config.ts";
|
||||||
|
import { emit } from "../core/events.ts";
|
||||||
import type { ChatStore } from "../core/ports.ts";
|
import type { ChatStore } from "../core/ports.ts";
|
||||||
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||||
import type { LhMessage } from "../core/native-messages.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`);
|
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 {
|
function createChat(title: string): ChatRecord {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const record: ChatRecord = {
|
const record: ChatRecord = {
|
||||||
@@ -32,6 +42,7 @@ function createChat(title: string): ChatRecord {
|
|||||||
};
|
};
|
||||||
fs.mkdirSync(PATHS.chats, { recursive: true });
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
||||||
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
emit("chats-changed", {});
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +97,8 @@ function listChats(): ChatSummary[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the full native message list (the record IS the agent state —
|
/** 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 {
|
function saveMessages(id: string, messages: LhMessage[]): void {
|
||||||
const record = getChat(id);
|
const record = getChat(id);
|
||||||
if (!record) throw new Error(`Chat not found: ${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}`);
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
record.title = title.trim() || "Untitled session";
|
record.title = title.trim() || "Untitled session";
|
||||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||||
|
emit("chats-changed", {});
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +164,7 @@ function renameChat(id: string, title: string): ChatRecord {
|
|||||||
function deleteChat(id: string): boolean {
|
function deleteChat(id: string): boolean {
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(chatFile(id));
|
fs.unlinkSync(chatFile(id));
|
||||||
|
emit("chats-changed", {}); // only a real removal — the present-entry law
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false; // never existed, already deleted, or unreadable — same answer
|
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