diff --git a/server/branching.test.ts b/server/branching.test.ts new file mode 100644 index 000000000..a3c67af08 --- /dev/null +++ b/server/branching.test.ts @@ -0,0 +1,226 @@ +// Conversation branching, end to end: boots the real harness server with +// the grokAgent driver on the fake ACP CLI, runs a real turn, edits the +// user message, and asserts the conversation forks — the old branch stays +// in the tree but off the active path, the edited branch gets its own +// reply, and version switching flips between the two. A second instance +// runs the fake in `hang` mode to pin the anti-double-generation contract: +// editing mid-turn interrupts the old turn and never leaves two turns (or +// two visible tails) running at once. +// +// Same POSIX gating as comms.test.ts (the fake CLI is a shebang script). +import { spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); +const FAKE_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); +const PORT = 18800 + Math.floor(Math.random() * 10_000); +const BASE = `http://127.0.0.1:${PORT}`; +const posixOnly = describe.skipIf(process.platform === "win32"); + +interface Msg { + id: string; + role: string; + kind: string; + text?: string; + parentId?: string | null; +} + +/** Client-side view of the active branch: walk parentId links from the leaf. */ +function activePath(messages: Msg[], leafId: string | null): Msg[] { + const byId = new Map(messages.map((m) => [m.id, m])); + const path: Msg[] = []; + let cur = leafId ? byId.get(leafId) : undefined; + while (cur) { + path.push(cur); + cur = cur.parentId ? byId.get(cur.parentId) : undefined; + } + return path.reverse(); +} + +posixOnly("conversation branching e2e (fake ACP fleet)", () => { + let child: ChildProcess; + let home: string; + let stderr = ""; + + const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json() }; + }; + + const getBot = async (id: string) => + (await api("GET", "/api/bots")).body.bots.find((b: any) => b.id === id); + + const waitFor = async (predicate: () => Promise, what: string, ms = 25_000) => { + const deadline = Date.now() + ms; + while (!(await predicate())) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}. stderr: ${stderr.slice(-2000)}`); + await new Promise((r) => setTimeout(r, 200)); + } + }; + + beforeAll(async () => { + chmodSync(FAKE_CLI, 0o755); + home = mkdtempSync(join(tmpdir(), "omb-branch-test-")); + mkdirSync(join(home, ".openmausbot"), { recursive: true }); + writeFileSync( + join(home, ".openmausbot", "config.json"), + JSON.stringify({ + instances: { + happy: { driver: "grokAgent", config: { cli: FAKE_CLI, fullAuto: true } }, + hang: { + driver: "grokAgent", + environment: { FAKE_ACP_MODE: "hang" }, + config: { cli: FAKE_CLI, fullAuto: true }, + }, + }, + }), + ); + + child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { + cwd: join(SERVER_DIR, ".."), + env: { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + HOME: home, + USERPROFILE: home, + OMB_PORT: String(PORT), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr!.on("data", (c) => (stderr += c)); + + const deadline = Date.now() + 20_000; + for (;;) { + try { + const res = await fetch(`${BASE}/api/health`); + if (res.ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`server never came up. stderr:\n${stderr}`); + if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}. stderr:\n${stderr}`); + await new Promise((r) => setTimeout(r, 150)); + } + }, 30_000); + + afterAll(async () => { + child?.kill("SIGTERM"); + await new Promise((resolve) => { + if (!child || child.exitCode !== null) return resolve(); + child.on("close", () => resolve()); + setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000).unref?.(); + }); + rmSync(home, { recursive: true, force: true }); + }); + + it( + "forks on edit, replies on the new branch, and switches versions cleanly", + async () => { + const created = (await api("POST", "/api/bots")).body.bot; + await api("PATCH", `/api/bots/${created.id}`, { + modelSelection: { instanceId: "happy", model: "fake-model" }, + }); + + // turn 1 settles on the original branch + expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "original question" })).status).toBe(202); + await waitFor(async () => { + const b = await getBot(created.id); + return !b.busy && b.messages.some((m: Msg) => m.role === "bot" && m.kind === "text" && m.text?.includes("fake acp")); + }, "the first reply"); + + let bot = await getBot(created.id); + const original: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "original question"); + const originalLeaf = bot.activeLeafId; + + // edit → fork + a fresh turn on the new branch + expect((await api("POST", `/api/bots/${created.id}/messages/${original.id}/edit`, { text: "edited question" })).status).toBe(202); + await waitFor(async () => { + const b = await getBot(created.id); + const edited = b.messages.find((m: Msg) => m.role === "user" && m.text === "edited question"); + if (!edited || b.busy) return false; + return activePath(b.messages, b.activeLeafId).some( + (m) => m.role === "bot" && m.kind === "text" && m.id !== originalLeaf, + ); + }, "the reply on the edited branch"); + + bot = await getBot(created.id); + const edited: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "edited question"); + expect(edited.parentId).toBe(original.parentId); // sibling versions + + // the visible path carries only the edited branch… + const path = activePath(bot.messages, bot.activeLeafId); + expect(path.map((m) => m.text)).toContain("edited question"); + expect(path.map((m) => m.text)).not.toContain("original question"); + // …while the old branch survives in the tree + expect(bot.messages.map((m: Msg) => m.id)).toContain(original.id); + + // version switch: back to the original branch and its own reply + const switched = await api("POST", `/api/bots/${created.id}/active-branch`, { messageId: original.id }); + expect(switched.status).toBe(200); + bot = await getBot(created.id); + const backPath = activePath(bot.messages, bot.activeLeafId); + expect(backPath.map((m) => m.text)).toContain("original question"); + expect(backPath.map((m) => m.text)).not.toContain("edited question"); + }, + 40_000, + ); + + it( + "refuses to rewind a live thread, then edits cleanly once it is stopped", + async () => { + const created = (await api("POST", "/api/bots")).body.bot; + await api("PATCH", `/api/bots/${created.id}`, { + modelSelection: { instanceId: "hang", model: "fake-model" }, + }); + + // start a turn that will never finish on its own + expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "first try" })).status).toBe(202); + await waitFor(async () => (await getBot(created.id)).busy === true, "the hung turn to start"); + + // a second send while busy is refused — never a parallel turn + const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" }); + expect(parallel.status).toBe(409); + + // switching versions under a live turn is refused too + const bot0 = await getBot(created.id); + const anyMsg = bot0.messages[0]; + expect((await api("POST", `/api/bots/${created.id}/active-branch`, { messageId: anyMsg.id })).status).toBe(409); + + // ...and so is editing: branching under a dying turn is what grows a + // second tail, so the thread must be stopped first + const first: Msg = bot0.messages.find((m: Msg) => m.role === "user" && m.text === "first try"); + const midTurn = await api("POST", `/api/bots/${created.id}/messages/${first.id}/edit`, { text: "second try" }); + expect(midTurn.status).toBe(409); + expect((await getBot(created.id)).messages.filter((m: Msg) => m.text === "second try")).toHaveLength(0); + + // stop the turn, then the same edit forks the conversation + expect((await api("POST", `/api/bots/${created.id}/interrupt`)).status).toBe(200); + await waitFor(async () => (await getBot(created.id)).busy === false, "the turn to settle", 20_000); + expect((await api("POST", `/api/bots/${created.id}/messages/${first.id}/edit`, { text: "second try" })).status).toBe(202); + + await waitFor(async () => { + const b = await getBot(created.id); + return b.messages.some((m: Msg) => m.role === "user" && m.text === "second try"); + }, "the forked message", 30_000); + + const bot = await getBot(created.id); + const second: Msg = bot.messages.find((m: Msg) => m.role === "user" && m.text === "second try"); + expect(second.parentId).toBe(first.parentId); + // exactly one visible tail: the fork is the leaf, the old branch is off-path + const path = activePath(bot.messages, bot.activeLeafId); + expect(path.at(-1)?.id).toBe(second.id); + expect(path.map((m) => m.text)).not.toContain("first try"); + // and only one copy of each attempt ever exists — no duplicated turns + expect(bot.messages.filter((m: Msg) => m.text === "first try")).toHaveLength(1); + expect(bot.messages.filter((m: Msg) => m.text === "second try")).toHaveLength(1); + }, + 45_000, + ); +}); diff --git a/server/index.test.ts b/server/index.test.ts index fc783f7dd..bcc802270 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -143,6 +143,43 @@ describe("harness HTTP API", () => { expect(send.body.error).toContain("unavailable"); }); + it("refuses to fork a message when the provider is unavailable, without mutating", async () => { + const { body } = await api("GET", "/api/bots"); + const bot = body.bots[0]; + const before = bot.messages.length; + + // greeting is a bot message — not editable + const greeting = bot.messages.find((m: { role: string }) => m.role === "bot"); + const notUser = await api("POST", `/api/bots/${bot.id}/messages/${greeting.id}/edit`, { text: "x" }); + expect(notUser.status).toBe(404); + + // no user message exists yet, so fabricate the check via the card id + const card = bot.messages.find((m: { kind: string }) => m.kind === "options"); + const res = await api("POST", `/api/bots/${bot.id}/messages/${card.id}/edit`, { text: "x" }); + expect(res.status).toBe(404); // options card, not a user text message + + const empty = await api("POST", `/api/bots/${bot.id}/messages/${greeting.id}/edit`, { text: " " }); + expect(empty.status).toBe(400); + + const after = await api("GET", "/api/bots"); + expect(after.body.bots[0].messages.length).toBe(before); + }); + + it("switches the active branch and reports the new leaf", async () => { + const { body } = await api("GET", "/api/bots"); + const bot = body.bots[0]; + expect(bot.activeLeafId).toBe(bot.messages.at(-1).id); + + // pointing at the first message descends back to the newest leaf on + // that (only) branch — a no-op switch, but it exercises the descent + const res = await api("POST", `/api/bots/${bot.id}/active-branch`, { messageId: bot.messages[0].id }); + expect(res.status).toBe(200); + expect(res.body.activeLeafId).toBe(bot.messages.at(-1).id); + + const missing = await api("POST", `/api/bots/${bot.id}/active-branch`, { messageId: "nope" }); + expect(missing.status).toBe(404); + }); + it("saves config keys write-only and reports booleans", async () => { const before = await api("GET", "/api/config"); expect(before.body.box).toEqual({ configured: false }); diff --git a/server/index.ts b/server/index.ts index 4633db4de..4812bbf5b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -282,7 +282,11 @@ function readCuaConnection(): { command: string; args: string[]; env: Record m.kind === "text" && m.text && m.id !== userMessage.id) .slice(-40) .map((m) => ({ role: m.role === "user" ? ("user" as const) : ("assistant" as const), text: m.text! })); + // After a rewind (edit / branch switch) the provider's native session + // still contains the abandoned branch: start a fresh session instead of + // resuming, and for cursor-resuming drivers replay the surviving path + // inline (transcript-replay drivers get it via transcript). The flag is + // cleared only once the turn is actually dispatched — clearing it here + // would cost the next attempt its history if this dispatch fails. + const rewound = Boolean(bot.rewound); + const turnText = + rewound && instance.driverKind !== "grok" && transcript.length + ? [ + "[The user rewound this conversation (edited a message or switched to another version). Everything before this point was replaced by the following history:]", + "", + ...transcript.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`), + "", + "[Now reply to the user's latest message:]", + "", + text, + ].join("\n") + : text; + const persona = [ `You are ${bot.name}, a personal bot in OpenMausBot.`, bot.title && `Role: ${bot.title}.`, @@ -368,9 +397,10 @@ async function startTurn(botId: string, text: string, opts?: { commsDepth?: numb await instance.adapter.sendTurn({ threadId: bot.threadId, - text, + text: turnText, model: bot.modelSelection.model, - resumeCursor: bot.resumeCursors[bot.modelSelection.instanceId], + // a rewound thread never resumes the abandoned branch's session + resumeCursor: rewound ? undefined : bot.resumeCursors[bot.modelSelection.instanceId], transcript, system: persona + @@ -389,6 +419,8 @@ async function startTurn(botId: string, text: string, opts?: { commsDepth?: numb : ""), integrations, }); + // dispatched: the rewind is spent, and the old cursors are dead + if (rewound) store.patchBot(bot.id, { rewound: false, resumeCursors: {} }); if (integrations.computer) startScreenPoller(bot.id); } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -523,13 +555,23 @@ const server = createServer(async (req, res) => { // ── bots ── if (method === "GET" && path === "/api/bots") { return json(res, 200, { - bots: store.bots.map((b) => ({ ...b, messages: store.messagesFor(b.threadId) })), + bots: store.bots.map((b) => ({ + ...b, + messages: store.messagesFor(b.threadId), + activeLeafId: store.activeLeaf(b.threadId), + })), }); } if (method === "POST" && path === "/api/bots") { const bot = store.createBot(); store.patchBot(bot.id, { modelSelection: await defaultSelection() }); - return json(res, 201, { bot: { ...store.bot(bot.id)!, messages: store.messagesFor(bot.threadId) } }); + return json(res, 201, { + bot: { + ...store.bot(bot.id)!, + messages: store.messagesFor(bot.threadId), + activeLeafId: store.activeLeaf(bot.threadId), + }, + }); } let m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "PATCH") { @@ -586,6 +628,55 @@ const server = createServer(async (req, res) => { await startTurn(m[1], text); return json(res, 202, { ok: true }); } + + // edit a user message → fork the conversation there and rerun the turn. + // Rewinding a live thread is refused, exactly like switching versions + // below: interrupting mid-flight and branching under the dying turn is + // how a conversation ends up with two tails. Stop, then edit. + m = path.match(/^\/api\/bots\/([\w-]+)\/messages\/([\w-]+)\/edit$/); + if (m && method === "POST") { + const messageId = m[2]; + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + const body = await readBody(req); + const text = String(body.text ?? "").trim(); + if (!text) return json(res, 400, { error: "text required" }); + // everything from here down is synchronous, so two racing edits can + // never both get past this check: startTurn flips busy before the + // next request is handled + if (bot.busy) return json(res, 409, { error: "the bot is working — stop it before editing" }); + const source = store.messagesFor(bot.threadId).find((msg) => msg.id === messageId); + if (!source || source.role !== "user" || source.kind !== "text") { + return json(res, 404, { error: "only user messages can be edited" }); + } + if (!registry.get(bot.modelSelection.instanceId)) { + return json(res, 409, { + error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`, + }); + } + const message = store.branchMessage(bot.threadId, messageId, text); + if (!message) return json(res, 404, { error: "no such message" }); + store.patchBot(bot.id, { rewound: true }); + broadcast({ kind: "message", threadId: bot.threadId, message }); + broadcast({ kind: "thread", threadId: bot.threadId, activeLeafId: message.id }); + await startTurn(bot.id, text, { userMessage: message }); + return json(res, 202, { ok: true }); + } + + // switch which fork of the conversation is visible (no new turn) + m = path.match(/^\/api\/bots\/([\w-]+)\/active-branch$/); + if (m && method === "POST") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.busy) return json(res, 409, { error: "the bot is working — stop it before switching versions" }); + const body = await readBody(req); + const leaf = store.setActiveLeaf(bot.threadId, String(body.messageId ?? "")); + if (!leaf) return json(res, 404, { error: "no such message" }); + // provider sessions still hold the other branch — next turn replays + store.patchBot(bot.id, { rewound: true }); + broadcast({ kind: "thread", threadId: bot.threadId, activeLeafId: leaf }); + return json(res, 200, { activeLeafId: leaf }); + } m = path.match(/^\/api\/bots\/([\w-]+)\/respond$/); if (m && method === "POST") { const bot = store.bot(m[1]); diff --git a/server/store.test.ts b/server/store.test.ts index 474f72dce..e2de098aa 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -95,6 +95,82 @@ describe("Store", () => { expect(reloaded.bots).toHaveLength(1); }); + it("chains appended messages and keeps the newest as active leaf", () => { + const store = new Store(selection); + const bot = store.createBot(); + const user = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: "hi" }); + + const messages = store.messagesFor(bot.threadId); + expect(user.parentId).toBe(messages[1].id); // follows the onboarding card + expect(store.activeLeaf(bot.threadId)).toBe(user.id); + expect(store.activePath(bot.threadId).map((m) => m.id)).toEqual(messages.map((m) => m.id)); + }); + + it("branchMessage forks at the edited message and hides the old tail", () => { + const store = new Store(selection); + const bot = store.createBot(); + const original = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: "v1" }); + const reply = store.appendMessage(bot.threadId, { role: "bot", kind: "text", text: "answer to v1" }); + + const edited = store.branchMessage(bot.threadId, original.id, "v2")!; + expect(edited.parentId).toBe(original.parentId); // sibling, not child + expect(store.activeLeaf(bot.threadId)).toBe(edited.id); + + const path = store.activePath(bot.threadId); + expect(path.map((m) => m.text)).toContain("v2"); + expect(path.map((m) => m.text)).not.toContain("v1"); + expect(path.map((m) => m.id)).not.toContain(reply.id); + // the abandoned branch still exists in the tree + expect(store.messagesFor(bot.threadId).map((m) => m.id)).toContain(original.id); + + expect(store.branchMessage(bot.threadId, "nope", "x")).toBeNull(); + }); + + it("setActiveLeaf switches branches and descends to the newest leaf", () => { + const store = new Store(selection); + const bot = store.createBot(); + const original = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: "v1" }); + const reply = store.appendMessage(bot.threadId, { role: "bot", kind: "text", text: "answer to v1" }); + store.branchMessage(bot.threadId, original.id, "v2"); + store.appendMessage(bot.threadId, { role: "bot", kind: "text", text: "answer to v2" }); + + // back to the original branch: the leaf is v1's reply, not v1 itself + expect(store.setActiveLeaf(bot.threadId, original.id)).toBe(reply.id); + const path = store.activePath(bot.threadId); + expect(path.map((m) => m.text)).toContain("v1"); + expect(path.map((m) => m.text)).not.toContain("v2"); + + expect(store.setActiveLeaf(bot.threadId, "nope")).toBeNull(); + }); + + it("persists the branch tree and active leaf across a restart", () => { + const store = new Store(selection); + const bot = store.createBot(); + const original = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: "v1" }); + const edited = store.branchMessage(bot.threadId, original.id, "v2")!; + + const reloaded = new Store(selection); + expect(reloaded.activeLeaf(bot.threadId)).toBe(edited.id); + expect(reloaded.messagesFor(bot.threadId).map((m) => m.text)).toContain("v1"); + expect(reloaded.activePath(bot.threadId).map((m) => m.text)).not.toContain("v1"); + }); + + it("migrates a pre-branching flat transcript file", () => { + const store = new Store(selection); + const bot = store.createBot(); + const legacy = [ + { id: "m1", role: "bot", kind: "text", text: "hello", at: 1 }, + { id: "m2", role: "user", kind: "text", text: "hi", at: 2 }, + ]; + writeFileSync(join(DATA_DIR, `messages-${bot.threadId}.json`), JSON.stringify(legacy)); + + const reloaded = new Store(selection); + const messages = reloaded.messagesFor(bot.threadId); + expect(messages.map((m) => m.parentId)).toEqual([null, "m1"]); + expect(reloaded.activeLeaf(bot.threadId)).toBe("m2"); + expect(reloaded.activePath(bot.threadId).map((m) => m.id)).toEqual(["m1", "m2"]); + }); + it("tolerates a corrupt bots.json by starting empty", () => { const store = new Store(selection); store.createBot(); diff --git a/server/store.ts b/server/store.ts index 65f614045..263c366aa 100644 --- a/server/store.ts +++ b/server/store.ts @@ -49,6 +49,9 @@ export interface Message { png?: string; mime?: string; at: number; + /** the message this one follows; null = thread root. Edited messages + * share a parentId with the version they replace — that's a fork. */ + parentId?: string | null; } export interface BotRecord { @@ -67,6 +70,10 @@ export interface BotRecord { /** which computer the bot acts on: its cloud box, this Mac (local CUA), * or none. Unset = auto (box when it exists, else local when available). */ computer?: "cloud" | "local" | "off"; + /** true after an edit/branch-switch rewound the visible conversation: + * provider sessions still hold the abandoned branch, so the next turn + * must start fresh (drop cursors) and replay the surviving path. */ + rewound?: boolean; pinned?: boolean; hidden?: boolean; busy?: boolean; @@ -115,9 +122,16 @@ const onboardingCard = (): OptionCardData => ({ options: ["Work & projects", "Writing & research", "Life admin", "A bit of everything"], }); +/** Messages form a tree (forks appear when a message is edited); the + * visible conversation is the path from the root to activeLeafId. */ +interface ThreadState { + messages: Message[]; + activeLeafId: string | null; +} + export class Store { bots: BotRecord[] = []; - private messages = new Map(); + private threads = new Map(); private defaultSelection: () => ModelSelection; constructor(defaultSelection: () => ModelSelection) { @@ -136,34 +150,114 @@ export class Store { writeFileSync(BOTS_FILE, JSON.stringify(this.bots, null, 2)); } - messagesFor(threadId: string): Message[] { - let list = this.messages.get(threadId); - if (!list) { - try { - list = JSON.parse(readFileSync(messagesFile(threadId), "utf8")); - } catch { - list = []; + private thread(threadId: string): ThreadState { + let t = this.threads.get(threadId); + if (t) return t; + let messages: Message[] = []; + let activeLeafId: string | null = null; + try { + const raw = JSON.parse(readFileSync(messagesFile(threadId), "utf8")); + if (Array.isArray(raw)) messages = raw; // pre-branching flat file + else { + messages = raw.messages ?? []; + activeLeafId = raw.activeLeafId ?? null; } - this.messages.set(threadId, list!); + } catch { + /* fresh thread */ + } + // legacy rows carry no parentId — chain them in array order + let prev: string | null = null; + for (const m of messages) { + if (m.parentId === undefined) m.parentId = prev; + prev = m.id; } - return list!; + if (!activeLeafId) activeLeafId = messages.at(-1)?.id ?? null; + t = { messages, activeLeafId }; + this.threads.set(threadId, t); + return t; + } + + private saveThread(threadId: string) { + const t = this.thread(threadId); + writeFileSync( + messagesFile(threadId), + JSON.stringify({ activeLeafId: t.activeLeafId, messages: t.messages }, null, 2), + ); + } + + messagesFor(threadId: string): Message[] { + return this.thread(threadId).messages; + } + + activeLeaf(threadId: string): string | null { + return this.thread(threadId).activeLeafId; + } + + /** The visible conversation: root → activeLeafId. */ + activePath(threadId: string): Message[] { + const t = this.thread(threadId); + const byId = new Map(t.messages.map((m) => [m.id, m])); + const path: Message[] = []; + let cur = t.activeLeafId ? byId.get(t.activeLeafId) : undefined; + while (cur) { + path.push(cur); + cur = cur.parentId ? byId.get(cur.parentId) : undefined; + } + return path.reverse(); } appendMessage(threadId: string, message: Omit & { at?: number }): Message { - const full: Message = { id: newId(), at: Date.now(), ...message }; - const list = this.messagesFor(threadId); - list.push(full); - writeFileSync(messagesFile(threadId), JSON.stringify(list, null, 2)); + const t = this.thread(threadId); + const full: Message = { id: newId(), at: Date.now(), parentId: t.activeLeafId, ...message }; + t.messages.push(full); + t.activeLeafId = full.id; + this.saveThread(threadId); + return full; + } + + /** Fork the conversation: a new user message that replaces `sourceId` + * (same parent, new text) and becomes the active leaf. */ + branchMessage(threadId: string, sourceId: string, text: string): Message | null { + const t = this.thread(threadId); + const source = t.messages.find((m) => m.id === sourceId); + if (!source) return null; + const full: Message = { + id: newId(), + at: Date.now(), + role: "user", + kind: "text", + text, + parentId: source.parentId ?? null, + }; + t.messages.push(full); + t.activeLeafId = full.id; + this.saveThread(threadId); return full; } + /** Point the visible conversation at the branch containing `messageId`, + * descending to that branch's most recently active leaf. */ + setActiveLeaf(threadId: string, messageId: string): string | null { + const t = this.thread(threadId); + if (!t.messages.some((m) => m.id === messageId)) return null; + let cur = messageId; + for (;;) { + const children = t.messages.filter((m) => m.parentId === cur); + if (!children.length) break; + cur = children.reduce((a, b) => (b.at >= a.at ? b : a)).id; + } + t.activeLeafId = cur; + this.saveThread(threadId); + return cur; + } + patchMessage(threadId: string, messageId: string, patch: Partial): Message | null { - const list = this.messagesFor(threadId); - const idx = list.findIndex((m) => m.id === messageId); + const t = this.thread(threadId); + const idx = t.messages.findIndex((m) => m.id === messageId); if (idx === -1) return null; - list[idx] = { ...list[idx], ...patch, card: patch.card ?? list[idx].card }; - writeFileSync(messagesFile(threadId), JSON.stringify(list, null, 2)); - return list[idx]; + t.messages[idx] = { ...t.messages[idx], ...patch, card: patch.card ?? t.messages[idx].card }; + this.saveThread(threadId); + return t.messages[idx]; } bot(id: string) { @@ -203,7 +297,7 @@ export class Store { const bot = this.bot(id); if (!bot) return false; this.bots = this.bots.filter((b) => b.id !== id); - this.messages.delete(bot.threadId); + this.threads.delete(bot.threadId); this.saveBots(); try { unlinkSync(messagesFile(bot.threadId)); diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index d3f6735e7..06c26dd95 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { ArrowDown, Check, Loader2, Monitor, Square, X } from "lucide-react"; -import { useStore, formatTime, type Bot, type Message } from "@/state/store"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowDown, Check, ChevronLeft, ChevronRight, Loader2, Monitor, Pencil, Square, X } from "lucide-react"; +import { useStore, formatTime, messageVersions, visibleMessages, type Bot, type Message } from "@/state/store"; import { MausAvatar } from "./Avatar"; import { stateForBot } from "@/lib/mascot"; import { ChatMarkdown } from "./ChatMarkdown"; @@ -14,37 +14,161 @@ import { cn } from "@/lib/cn"; const USER_COLLAPSE_CHARS = 600; const USER_COLLAPSE_LINES = 8; -function Bubble({ message }: { message: Message }) { +/** Inline editor a user bubble turns into: Enter sends (forking the + * conversation), Esc cancels. Shift+Enter for a newline, like everywhere. */ +function BubbleEditor({ + initial, + onCancel, + onSubmit, +}: { + initial: string; + onCancel: () => void; + onSubmit: (text: string) => void; +}) { + const [draft, setDraft] = useState(initial); + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }, []); + const submit = () => { + if (draft.trim()) onSubmit(draft.trim()); + }; + return ( +
+