feat(chat): add local-first conversation workspace

This commit is contained in:
2026-08-13 15:55:15 +08:00
parent d1d50d722c
commit 8991f78f9f
43 changed files with 5450 additions and 592 deletions
@@ -0,0 +1,73 @@
import {describe, expect, test} from "bun:test";
import {IncrementalMarkdownCache} from "./incremental-markdown-cache.ts";
describe("incremental markdown cache", () => {
test("never parses a stable prefix again", () => {
const cache = new IncrementalMarkdownCache();
const calls = [];
const render = (source, type, index, stable) => {
calls.push({source, type, index, stable});
return {rendered: source.toUpperCase()};
};
cache.render("message", "first", render);
const afterFirst = calls.length;
const closed = cache.render("message", "first\n\n", render);
const afterClosed = calls.length;
const growingTail = cache.render("message", "first\n\nsecond", render);
const longerTail = cache.render("message", "first\n\nsecond grows", render);
expect(afterFirst).toBe(1);
expect(afterClosed).toBe(2);
expect(closed.blocks[0].stable).toBe(true);
expect(growingTail.reused).toBe(1);
expect(growingTail.parsed).toBe(1);
expect(longerTail.reused).toBe(1);
expect(longerTail.parsed).toBe(1);
expect(calls.filter((call) => call.source === "first")).toHaveLength(2);
});
test("assigns stable indexes as blocks become frozen", () => {
const cache = new IncrementalMarkdownCache();
const render = () => ({});
const first = cache.render("message", "one\n\ntwo", render);
const second = cache.render("message", "one\n\ntwo\n\nthree", render);
expect(first.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, false]]);
expect(second.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, true], [2, false]]);
});
test("resets when the source is edited before the stable prefix", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("message", "one\n\ntwo", render);
const result = cache.render("message", "changed\n\ntwo", render);
expect(result.reused).toBe(0);
expect(result.blocks[0].source).toBe("changed");
});
test("moves cached blocks when a working message receives its immutable id", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("working", "first\n\n", render);
cache.move("working", "immutable");
const result = cache.render("immutable", "first\n\nsecond", render);
expect(cache.has("working")).toBe(false);
expect(cache.has("immutable")).toBe(true);
expect(result.reused).toBe(1);
expect(result.blocks.map((block) => block.index)).toEqual([0, 1]);
});
test("freezes the final open block when the stream completes", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("message", "last paragraph", render);
const result = cache.render("message", "last paragraph", render, undefined, true);
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0].stable).toBe(true);
});
});