From b821d980add47adea975f5a87d246db85ca587ca Mon Sep 17 00:00:00 2001 From: piggidragon Date: Sat, 19 Sep 2026 21:00:36 +0200 Subject: [PATCH 1/5] Resend, edit and delete sent messages; rename and delete Agent chats Each message you send now has three actions on hover. Resend sends the same text again. Edit replaces the message and everything after it. Delete removes the message and the agent's answer to it. Edit and delete change pi's own session file as well as the portal's transcript. Trimming only the transcript would leave the model answering to a message that is gone from the screen. The file is a tree of entries linked by parent id, so removing a turn means dropping its entries and re-parenting the next one; that lives in pi/session-edit.ts as a function over the file's text, tested against synthetic sessions and checked against a real one loaded with pi's own SessionManager. It refuses instead of guessing when a message sits under a compaction summary or the file has branches it cannot reason about. The server refuses both while a run is in progress. A portal_removed event tells open pages which stretch of transcript to drop. The Agent tab lists conversations that could already be renamed and deleted through the session routes, but had no way to do it. Rows now have both. Co-Authored-By: Claude Sonnet 5 --- docs/channels/index.md | 7 ++ docs/guide/sessions.md | 28 +++++ server/src/db.ts | 15 +++ server/src/index.ts | 31 +++++ server/src/pi/session-edit.ts | 202 ++++++++++++++++++++++++++++++ server/src/session-manager.ts | 59 ++++++++- server/test/session-edit.test.mjs | 128 +++++++++++++++++++ web/src/App.tsx | 14 +++ web/src/api.ts | 9 ++ web/src/components/AgentPage.tsx | 66 +++++++++- web/src/components/Chat.tsx | 164 +++++++++++++++++++++++- web/src/transcript.ts | 4 +- 12 files changed, 720 insertions(+), 7 deletions(-) create mode 100644 server/src/pi/session-edit.ts create mode 100644 server/test/session-edit.test.mjs diff --git a/docs/channels/index.md b/docs/channels/index.md index 2b210eb5..1c53e113 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -38,6 +38,13 @@ on the Agent tab marked "no channel" until something claims that slug again. The same replay, same model handling — and they are listed on the **Agent** tab, where clicking one opens it in the normal chat view. +From that list a conversation can be renamed or deleted. Deleting one does not +block the chat it belonged to: the next message in it starts a new conversation, +with none of the old memory. That makes it the way to reset a chat the agent has +got wrong. The messages themselves can be edited or deleted as in any session — +see [Sent messages](/guide/sessions#sent-messages) — but that changes what the +agent remembers, not what was already delivered to the channel. + ## Channel types are packages Nothing about Telegram is hardcoded. A channel type is a package with a diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index c933af37..82e14abd 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -25,6 +25,32 @@ not wait for the work to finish. Close the tab if you like. While a run is in progress you can keep typing; further messages are queued. **Stop** aborts the current run. +## Sent messages + +Hovering one of your messages gives it three actions: + +- **Send again** sends the same text as a new message. While a run is going it + queues, like anything else you type. +- **Edit** rewrites the message in place. It replaces that message *and + everything after it* — the agent's answers were to a question that is no + longer the same one — and sends the new text. +- **Delete** removes the message and the agent's answer to it, tool calls + included, and leaves the rest of the conversation as it was. + +Edit and delete change what the agent remembers, not just what the page shows: +pi's own record of the conversation is edited too, and the agent's next turn +reads the version without the message. They are unavailable while a run is in +progress, so stop it first. + +They do not undo what the agent *did*. Files it changed, commands it ran and +messages it sent stay as they are; only the memory of having done them goes. + +Some cases are refused rather than guessed at, with a message saying why. A +message that a compaction has already folded into its summary cannot be deleted +on its own, since the summary would go on describing it — edit it instead, which +drops the summary with everything after. A conversation with branches from pi's +`/tree` cannot be trimmed cleanly either. Nothing is changed when this happens. + ## Sidebar and the sessions page The sidebar opens with New, Sessions and Agents, then **Pinned**, then @@ -36,6 +62,8 @@ Pinning is stored server-side and drives the ordering (`pinned DESC, updated_at DESC`), so the sidebar and the Sessions page never disagree. Hovering a session gives you pin and delete. Double-clicking its name renames it. +The Agent tab's conversations can be renamed and deleted the same way, from the +row. ## Model and effort diff --git a/server/src/db.ts b/server/src/db.ts index 336f6e9b..8372c297 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -554,6 +554,21 @@ export function replayStart(sessionId: string, keep: number): number { return row?.seq ?? 0; } +/** Every message the portal sent to the agent in this session, oldest first. */ +export function sentMessages(sessionId: string): { seq: number; message: string }[] { + const rows = getDb() + .prepare("SELECT seq, payload FROM events WHERE session_id = ? AND type = 'portal_prompt' ORDER BY seq ASC") + .all(sessionId) as { seq: number; payload: string }[]; + return rows.map((r) => ({ seq: r.seq, message: String(JSON.parse(r.payload)?.message ?? "") })); +} + +/** Drops a stretch of a session's transcript: `from` up to, not including, `to` — or to the end. */ +export function deleteEventsBetween(sessionId: string, from: number, to: number | null): void { + getDb() + .prepare("DELETE FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?)") + .run(sessionId, from, to, to); +} + /** The page before a cursor, oldest first — what a transcript scrolls back into. */ export function eventsBefore(sessionId: string, before: number, limit = 1500): EventRow[] { return getDb() diff --git a/server/src/index.ts b/server/src/index.ts index 19aa9bae..c8b24c34 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -51,6 +51,7 @@ import { } from "./pi-settings.js"; import { eventTime, getDb } from "./db.js"; import { getBuiltinCommands } from "./pi/builtins.js"; +import { SessionEditError } from "./pi/session-edit.js"; import { isValidSlug, slugify } from "./slug.js"; import { getSettingDefaults, getSettings, getStoredSettings, setSettings } from "./db.js"; @@ -387,6 +388,36 @@ app.post("/api/sessions/:id/prompt", async (req, res) => { } }); +// --- editing the conversation --- + +const editStatus = { busy: 409, missing: 404 } as const; + +/** Removes a message and the agent's answer to it. */ +app.delete("/api/sessions/:id/messages/:seq", async (req, res) => { + try { + await sessions.removeMessage(req.params.id, Number(req.params.seq), "turn"); + res.json({ ok: true }); + } catch (e) { + if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message }); + res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message }); + } +}); + +/** Replaces a message: it and everything after it are dropped, and the new text is sent. */ +app.post("/api/sessions/:id/messages/:seq/edit", async (req, res) => { + const message = req.body?.message; + if (typeof message !== "string" || !message.trim()) { + return res.status(400).json({ error: "message required" }); + } + try { + await sessions.editMessage(req.params.id, Number(req.params.seq), message); + res.json({ ok: true, status: "running" }); + } catch (e) { + if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message }); + res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message }); + } +}); + /** The browser answering a dialog an extension is waiting on. */ app.post("/api/sessions/:id/ui-response", (req, res) => { const session = getSession(req.params.id); diff --git a/server/src/pi/session-edit.ts b/server/src/pi/session-edit.ts new file mode 100644 index 00000000..b4fe0754 --- /dev/null +++ b/server/src/pi/session-edit.ts @@ -0,0 +1,202 @@ +import { AUDIO_MESSAGE_PREFIX } from "./voice-first.js"; + +/** + * Taking a message back out of pi's own record of the conversation. + * + * The portal's transcript is its own log, so trimming that is easy — and does + * nothing for the model, which reads pi's session file. A message deleted from + * the screen but still in the file is one the agent goes on answering to, which + * is worse than not offering delete at all. So the file is edited too. + * + * pi's file is a tree: every entry names its parent, and the conversation is + * the path from the last entry back to the root. Removing entries means + * removing them from that path and stitching the rest together, which is all + * this does. It works on the text of the file and touches nothing else, so it + * can be checked against a copy without a live session. + */ + +export type SessionEditCode = + | "busy" + | "missing" + | "unsupported" + | "unmatched" + | "branched" + | "compacted"; + +export class SessionEditError extends Error { + constructor( + readonly code: SessionEditCode, + message: string, + ) { + super(message); + } +} + +/** + * `turn`: this message and what the agent did about it, and nothing else. + * `tail`: this message and everything after it — what editing does, since a + * conversation cannot keep answers to a question that is no longer the same. + */ +export type Scope = "turn" | "tail"; + +interface Entry { + type: string; + id?: string; + parentId?: string | null; + message?: { role?: string; content?: unknown }; + targetId?: string; + [key: string]: unknown; +} + +const textOf = (content: unknown): string => + typeof content === "string" + ? content + : Array.isArray(content) + ? content.map((c) => (c?.type === "text" ? (c.text ?? "") : "")).join("") + : ""; + +const isUser = (e: Entry) => e.type === "message" && e.message?.role === "user"; + +/** Root to leaf, following parent links from the last entry. */ +function pathTo(byId: Map, leaf: string): Entry[] { + const out: Entry[] = []; + const seen = new Set(); + for (let id: string | null | undefined = leaf; id && !seen.has(id); ) { + seen.add(id); + const entry = byId.get(id); + if (!entry) break; + out.push(entry); + id = entry.parentId; + } + return out.reverse(); +} + +/** + * Which of pi's user entries a message the portal sent corresponds to. + * + * Matched by text, in order. The portal logs every message it sends and pi + * stores every one it receives, but they are not the same list: a slash command + * is not a chat message to the portal and can expand into one for pi, and a + * message that failed before reaching pi has no entry at all. So each sent + * message looks forward from the last one matched, and one that finds nothing + * is skipped rather than stopping the count — only the message being asked + * about has to be found. + */ +function locate(path: Entry[], sent: string[], ordinal: number): string { + const users = path.filter(isUser).map((e) => ({ id: e.id!, text: textOf(e.message?.content) })); + let from = 0; + let found = -1; + for (let i = 0; i <= ordinal; i++) { + const want = sent[i] ?? ""; + let hit = users.findIndex( + (u, j) => j >= from && (u.text === want || u.text === AUDIO_MESSAGE_PREFIX + want), + ); + if (hit < 0 && want) hit = users.findIndex((u, j) => j >= from && u.text.includes(want)); + if (hit < 0) { + if (i === ordinal) { + throw new SessionEditError( + "unmatched", + "This message could not be found in the agent's history, so nothing was changed.", + ); + } + continue; + } + from = hit + 1; + found = i === ordinal ? hit : found; + } + return users[found].id; +} + +/** + * The session file with one message taken out. + * + * Refuses rather than guesses. If the tree has branches this cannot reason + * about, or the message sits inside a summary the agent has already written, + * the honest answer is that it cannot be done cleanly — a file left half-edited + * would corrupt every later turn, and a refusal costs nothing. + * + * @param sent every message the portal sent up to and including this one, oldest first + * @param ordinal which of them this is + */ +export function dropMessage(raw: string, sent: string[], ordinal: number, scope: Scope): string { + const rows = raw + .split("\n") + .filter((line) => line.trim()) + .map((line) => ({ line, entry: JSON.parse(line) as Entry })); + const header = rows.filter((r) => r.entry.type === "session"); + const body = rows.filter((r) => r.entry.type !== "session"); + const byId = new Map(body.map((r) => [r.entry.id!, r.entry])); + + // pi opens a file with its last entry as the leaf. + const leaf = body.at(-1)?.entry.id; + if (!leaf) throw new SessionEditError("unmatched", "The agent has no history to edit."); + const path = pathTo(byId, leaf); + const onPath = new Set(path.map((e) => e.id!)); + + const target = locate(path, sent, ordinal); + const at = path.findIndex((e) => e.id === target); + + const removed = new Set(); + if (scope === "tail") { + for (const e of path.slice(at)) removed.add(e.id!); + } else { + // Up to the next thing the person said. What lies between is the agent's + // answer: its replies, its tool calls, and their results. + const next = path.findIndex((e, i) => i > at && isUser(e)); + const span = path.slice(at, next < 0 ? path.length : next); + + // A summary already stands in for these messages. Deleting them leaves the + // summary describing something that never happened. + if (path.slice(at).some((e) => e.type === "compaction")) { + throw new SessionEditError( + "compacted", + "This message was already folded into a compacted summary and can no longer be removed on its own.", + ); + } + // Only messages. A model or thinking-level change that fell in between is + // still true of the conversation, so it stays. + for (const e of span) if (e.type === "message" || e.type === "custom_message") removed.add(e.id!); + } + + // Whatever hangs off a removed entry off the main path goes with it. + for (const { entry } of body) { + if (entry.parentId && removed.has(entry.parentId) && !onPath.has(entry.id!)) removed.add(entry.id!); + } + // A label on something that no longer exists. + for (const { entry } of body) { + if (entry.type === "label" && entry.targetId && removed.has(entry.targetId)) removed.add(entry.id!); + } + + const parentOf = (id: string | null | undefined): string | null => { + let cur = id ?? null; + while (cur && removed.has(cur)) cur = byId.get(cur)?.parentId ?? null; + return cur; + }; + + const kept = body + .filter((r) => !removed.has(r.entry.id!)) + .map((r) => { + const parent = parentOf(r.entry.parentId); + return parent === (r.entry.parentId ?? null) + ? r + : { line: JSON.stringify({ ...r.entry, parentId: parent }), entry: { ...r.entry, parentId: parent } }; + }); + + // The result must be the old conversation minus exactly what was removed. + // If a side branch made the last entry something else, opening the file + // would silently land on a different conversation — so check before writing. + const after = new Map(kept.map((r) => [r.entry.id!, r.entry])); + const newLeaf = kept.at(-1)?.entry.id; + const actual = newLeaf ? pathTo(after, newLeaf).map((e) => e.id) : []; + const expected = (scope === "tail" ? path.slice(0, at) : path.filter((e) => !removed.has(e.id!))).map( + (e) => e.id, + ); + if (actual.length !== expected.length || actual.some((id, i) => id !== expected[i])) { + throw new SessionEditError( + "branched", + "This conversation has branches, so a message cannot be removed cleanly. Nothing was changed.", + ); + } + + return [...header, ...kept].map((r) => r.line).join("\n") + "\n"; +} diff --git a/server/src/session-manager.ts b/server/src/session-manager.ts index 38f55a7f..e64d84b8 100644 --- a/server/src/session-manager.ts +++ b/server/src/session-manager.ts @@ -1,14 +1,17 @@ import { LiveEvents } from "./live-events.js"; import { EventEmitter } from "node:events"; import type { PersonRow, Role } from "./people.js"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { PiClient } from "./pi/types.js"; import { findServerBuiltin, runBuiltin } from "./pi/builtins.js"; +import { dropMessage, SessionEditError, type Scope } from "./pi/session-edit.js"; import { buildExecutor, type Executor, type ExecutorKind } from "./executors/index.js"; import { appendEvent, + deleteEventsBetween, getSession, + sentMessages, getSettings, markOrphanedSessionsInterrupted, browserAllowed, @@ -60,6 +63,9 @@ const EPHEMERAL_EVENTS = new Set([ // Prefill progress: a hundred rows per long prompt, and meaningless once the // answer has arrived. Delivered to whoever is watching, never stored. "portal_prefill", + // Tells a page which stretch of its transcript is gone. Stored, it would be + // replayed to a reader who never saw what it refers to. + "portal_removed", ]); interface LiveSession { @@ -386,6 +392,57 @@ class SessionManager extends EventEmitter { if (client.isIdle?.()) this.mark(sessionId, "idle"); } + /** + * Take a message back out of the conversation. + * + * Out of pi's record as well as the transcript: see session-edit.ts for why + * the second half is the point. Refused while a run is going — the agent + * would be answering something that is being removed underneath it. + */ + async removeMessage(sessionId: string, seq: number, scope: Scope): Promise { + const session = getSession(sessionId); + if (!session) throw new SessionEditError("missing", "Unknown session"); + if (this.isBusy(sessionId) || this.compacting.has(sessionId)) { + throw new SessionEditError("busy", "Stop the run first — the agent is still working."); + } + + const sent = sentMessages(sessionId); + const ordinal = sent.findIndex((m) => m.seq === seq); + if (ordinal < 0) throw new SessionEditError("missing", "That message is not in this conversation"); + + // Released before the file changes: a live pi holds the conversation in + // memory and would write its own version back over the edit. The next + // prompt reopens it from the file. + await this.stop(sessionId); + + const file = session.pi_session_file; + if (file && existsSync(file)) { + const edited = dropMessage( + readFileSync(file, "utf8"), + sent.slice(0, ordinal + 1).map((m) => m.message), + ordinal, + scope, + ); + // Beside it, then renamed over it, so a crash mid-write leaves the + // original rather than half of each. + const tmp = `${file}.edit`; + writeFileSync(tmp, edited); + renameSync(tmp, file); + } else if (session.executor !== "host") { + throw new SessionEditError("unsupported", "Messages cannot be edited in a container session."); + } + + const to = scope === "tail" ? null : (sent[ordinal + 1]?.seq ?? null); + deleteEventsBetween(sessionId, seq, to); + this.record(sessionId, "portal_removed", { from: seq, to }); + } + + /** Replace a message: everything from it onwards goes, and the new text is sent in its place. */ + async editMessage(sessionId: string, seq: number, message: string): Promise { + await this.removeMessage(sessionId, seq, "tail"); + await this.prompt(sessionId, message); + } + /** * Prompt and wait for the answer. * diff --git a/server/test/session-edit.test.mjs b/server/test/session-edit.test.mjs new file mode 100644 index 00000000..96010392 --- /dev/null +++ b/server/test/session-edit.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +const { dropMessage, SessionEditError } = await import('../dist/pi/session-edit.js'); + +const user = (id, parentId, text) => ({ type: 'message', id, parentId, message: { role: 'user', content: [{ type: 'text', text }] } }); +const asst = (id, parentId, text = 'ok') => ({ type: 'message', id, parentId, message: { role: 'assistant', content: [{ type: 'text', text }] } }); +const tool = (id, parentId) => ({ type: 'message', id, parentId, message: { role: 'toolResult', content: [{ type: 'text', text: 'out' }] } }); +const file = (...entries) => [{ type: 'session', version: 3, id: 'hdr' }, ...entries].map((e) => JSON.stringify(e)).join('\n') + '\n'; +const parse = (raw) => raw.split('\n').filter(Boolean).map((l) => JSON.parse(l)); +const ids = (raw) => parse(raw).filter((e) => e.type !== 'session').map((e) => e.id); +/** What pi would open: the last entry, walked back to the root. */ +const conversation = (raw) => { + const body = parse(raw).filter((e) => e.type !== 'session'); + const byId = new Map(body.map((e) => [e.id, e])); + const out = []; + for (let id = body.at(-1)?.id; id; id = byId.get(id).parentId) out.unshift(id); + return out; +}; + +const three = file( + user('u1', null, 'first'), asst('a1', 'u1'), + user('u2', 'a1', 'second'), asst('t2', 'u2'), tool('r2', 't2'), asst('a2', 'r2'), + user('u3', 'a2', 'third'), asst('a3', 'u3'), +); + +test('deleting a middle turn removes its answer and joins the neighbours', () => { + const out = dropMessage(three, ['first', 'second'], 1, 'turn'); + assert.deepEqual(conversation(out), ['u1', 'a1', 'u3', 'a3']); + assert.equal(parse(out).find((e) => e.id === 'u3').parentId, 'a1'); + // The header is untouched and the untouched lines are byte-for-byte the same. + assert.equal(parse(out)[0].type, 'session'); + assert.ok(out.includes(JSON.stringify(user('u1', null, 'first')))); +}); + +test('deleting the last turn leaves the earlier conversation as it was', () => { + const out = dropMessage(three, ['first', 'second', 'third'], 2, 'turn'); + assert.deepEqual(conversation(out), ['u1', 'a1', 'u2', 't2', 'r2', 'a2']); +}); + +test('deleting the first turn makes the second the root', () => { + const out = dropMessage(three, ['first'], 0, 'turn'); + assert.deepEqual(conversation(out), ['u2', 't2', 'r2', 'a2', 'u3', 'a3']); + assert.equal(parse(out).find((e) => e.id === 'u2').parentId, null); +}); + +test('editing drops the message and everything after it', () => { + const out = dropMessage(three, ['first', 'second'], 1, 'tail'); + assert.deepEqual(conversation(out), ['u1', 'a1']); + assert.deepEqual(ids(out), ['u1', 'a1']); +}); + +test('editing the first message empties the conversation', () => { + const out = dropMessage(three, ['first'], 0, 'tail'); + assert.deepEqual(ids(out), []); + assert.equal(parse(out).length, 1); +}); + +test('the same words sent twice are told apart by order', () => { + const twice = file(user('u1', null, 'again'), asst('a1', 'u1'), user('u2', 'a1', 'again'), asst('a2', 'u2')); + assert.deepEqual(conversation(dropMessage(twice, ['again', 'again'], 1, 'turn')), ['u1', 'a1']); + assert.deepEqual(conversation(dropMessage(twice, ['again', 'again'], 0, 'turn')), ['u2', 'a2']); +}); + +test('a message pi never received does not throw the count off', () => { + // "lost" failed before reaching pi, so it has a portal event and no entry. + const out = dropMessage(three, ['first', 'lost', 'second'], 2, 'turn'); + assert.deepEqual(conversation(out), ['u1', 'a1', 'u3', 'a3']); +}); + +test('a voice message is found under its audio prefix', () => { + const voiced = file(user('u1', null, '[Audio mode]\nhello'), asst('a1', 'u1')); + assert.deepEqual(ids(dropMessage(voiced, ['hello'], 0, 'turn')), []); +}); + +test('a model change between turns survives the delete', () => { + const changed = file( + user('u1', null, 'first'), asst('a1', 'u1'), { type: 'model_change', id: 'm1', parentId: 'a1' }, + user('u2', 'm1', 'second'), asst('a2', 'u2'), + ); + const out = dropMessage(changed, ['first', 'second'], 0, 'turn'); + assert.deepEqual(conversation(out), ['m1', 'u2', 'a2']); +}); + +test('a label on a removed message goes with it', () => { + const labelled = file(user('u1', null, 'first'), asst('a1', 'u1'), { type: 'label', id: 'l1', parentId: 'a1', targetId: 'u1' }, user('u2', 'l1', 'second')); + const out = dropMessage(labelled, ['first', 'second'], 0, 'turn'); + assert.ok(!ids(out).includes('l1')); + assert.deepEqual(conversation(out), ['u2']); +}); + +test('a message under a later compaction cannot be deleted alone, but can be edited away', () => { + const compacted = file( + user('u1', null, 'first'), asst('a1', 'u1'), + user('u2', 'a1', 'second'), asst('a2', 'u2'), + { type: 'compaction', id: 'c1', parentId: 'a2', firstKeptEntryId: 'u2', summary: 's' }, + user('u3', 'c1', 'third'), + ); + assert.throws(() => dropMessage(compacted, ['first', 'second', 'third'], 1, 'turn'), (e) => e instanceof SessionEditError && e.code === 'compacted'); + assert.deepEqual(conversation(dropMessage(compacted, ['first', 'second'], 1, 'tail')), ['u1', 'a1']); + // Before the compaction is another matter: nothing summarises what came after. + assert.deepEqual(conversation(dropMessage(compacted, ['first', 'second', 'third'], 2, 'turn')), ['u1', 'a1', 'u2', 'a2', 'c1']); +}); + +test('a message that is not in the history is refused, not guessed at', () => { + assert.throws(() => dropMessage(three, ['first', 'never sent'], 1, 'turn'), (e) => e.code === 'unmatched'); +}); + +test('a side branch makes the last entry ambiguous, so nothing is changed', () => { + const branched = file( + user('u1', null, 'first'), asst('a1', 'u1'), + user('u2', 'a1', 'second'), asst('a2', 'u2'), + asst('side', 'u1'), // a branch off the first turn, written last + ); + // The conversation pi opens is u1 → side; asking about 'second' finds nothing on it. + assert.throws(() => dropMessage(branched, ['first', 'second'], 1, 'turn'), SessionEditError); +}); + +test('a dead branch hanging off a removed message is removed with it', () => { + const dead = file( + user('u1', null, 'first'), asst('dead', 'u1'), asst('a1', 'u1'), + user('u2', 'a1', 'second'), asst('a2', 'u2'), + ); + // 'dead' is written before the live answer, so the path is u1 → a1 → u2 → a2. + const out = dropMessage(dead, ['first', 'second'], 0, 'turn'); + assert.ok(!ids(out).includes('a1')); + assert.ok(!ids(out).includes('dead')); + assert.deepEqual(conversation(out), ['u2', 'a2']); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 15bce4ff..c5c34b0e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -139,6 +139,13 @@ function Shell({ es.addEventListener("live-reset", () => setEvents(resetLiveEvents)); es.onmessage = (m) => { const ev: PortalEvent = JSON.parse(m.data); + // A message was taken out of the conversation: drop what it covered, + // rather than reloading everything to find out what is left. + if (ev.type === "portal_removed") { + const { from, to } = ev.payload as { from: number; to: number | null }; + setEvents((prev) => prev.filter((e) => !(e.seq >= from && (to == null || e.seq < to)))); + return; + } // Live-only events (dialogs) use a negative seq and must not move the // resume cursor, or reconnecting would skip real history. if (ev.seq > 0) seq = ev.seq; @@ -308,6 +315,13 @@ function Shell({ await api.prompt(active.id, msg, options); refreshSessions(); }} + onEditMessage={async (seq, message) => { + await api.editMessage(active.id, seq, message); + refreshSessions(); + }} + onDeleteMessage={async (seq) => { + await api.deleteMessage(active.id, seq); + }} onAbort={async () => { await api.abort(active.id); refreshSessions(); diff --git a/web/src/api.ts b/web/src/api.ts index e0dd005f..65f294e5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -175,6 +175,15 @@ export const api = { method: "POST", body: JSON.stringify({ message, ...(options?.voice ? { voice: true } : {}) }), }), + /** Removes a message and the agent's answer to it — from the agent's memory too. */ + deleteMessage: (id: string, seq: number) => + json<{ ok: true }>(`/api/sessions/${id}/messages/${seq}`, { method: "DELETE" }), + /** Replaces a message: it and everything after it are dropped, and the new text is sent. */ + editMessage: (id: string, seq: number, message: string) => + json<{ ok: true }>(`/api/sessions/${id}/messages/${seq}/edit`, { + method: "POST", + body: JSON.stringify({ message }), + }), respondUi: (sessionId: string, id: string, payload: { value?: unknown; cancelled?: boolean }) => json<{ ok: boolean }>(`/api/sessions/${sessionId}/ui-response`, { method: "POST", diff --git a/web/src/components/AgentPage.tsx b/web/src/components/AgentPage.tsx index 433d5176..44b6944d 100644 --- a/web/src/components/AgentPage.tsx +++ b/web/src/components/AgentPage.tsx @@ -6,9 +6,11 @@ import { LuFolder, LuMessageSquare, LuMonitor, + LuPencil, LuPlus, LuRadio, LuRefreshCw, + LuTrash2, } from "react-icons/lu"; import { api, type AgentSession, type AgentSetup as Setup, type SessionStatus } from "../api"; import { AgentSetup } from "./AgentSetup"; @@ -47,6 +49,7 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) { const [setup, setSetup] = useState(null); const [loading, setLoading] = useState(true); const [starting, setStarting] = useState(false); + const [error, setError] = useState(""); const load = () => api @@ -65,6 +68,41 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) { return () => clearInterval(t); }, []); + /** + * Rename and delete, as the sidebar does them: these are the same sessions, + * and the same two routes. Deleting one that arrived through a channel does + * not block the chat — the next message in it simply starts a new + * conversation, which is the reason to say so first. + */ + const rename = async (s: AgentSession) => { + const next = prompt("Rename conversation", s.title)?.trim(); + if (!next || next === s.title) return; + setError(""); + try { + await api.renameSession(s.id, next); + await load(); + } catch (e) { + setError((e as Error).message); + } + }; + + const remove = async (s: AgentSession) => { + const fresh = s.channel && s.channel.slug !== BROWSER; + const ok = confirm( + fresh + ? `Delete "${s.title}"? The agent forgets this conversation, and the next message in that chat starts a new one.` + : `Delete "${s.title}"? This stops it if it is running.`, + ); + if (!ok) return; + setError(""); + try { + await api.deleteSession(s.id); + await load(); + } catch (e) { + setError((e as Error).message); + } + }; + // Grouped by the door each conversation came through. const groups = useMemo(() => { const out = new Map< @@ -156,6 +194,10 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) { {setup?.initialised && } + {error && ( +
{error}
+ )} + {loading ? (

Loading…

) : sessions.length === 0 ? ( @@ -197,7 +239,7 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) {
    {group.items.map((s) => ( -
  • +
  • + {/* Over the timestamp rather than beside it: the row is a + button, and one button cannot hold another. */} +
    + + +
  • ))}
diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx index e6374d4f..334a94e0 100644 --- a/web/src/components/Chat.tsx +++ b/web/src/components/Chat.tsx @@ -6,7 +6,7 @@ import { latestBrowserActivity, latestTerminalActivity } from "../voice-browser" import { VoiceControl } from "./VoiceControl"; import { useEffect, useMemo, useRef, useState } from "react"; import { Streamdown, type DiagramPlugin } from "streamdown"; -import { LuGlobe, LuSquareTerminal, LuSquare, LuFileText, LuArrowUp, LuAudioLines } from "react-icons/lu"; +import { LuGlobe, LuSquareTerminal, LuSquare, LuFileText, LuArrowUp, LuAudioLines, LuPencil, LuRotateCw, LuTrash2 } from "react-icons/lu"; import { api, type PiCommand, type PortalEvent, type Session } from "../api"; import { activity, buildTranscript, type Activity } from "../transcript"; import { HAS_MERMAID, loadMermaidPlugin } from "../mermaid"; @@ -78,6 +78,8 @@ export function Chat({ session, events, onSend, + onEditMessage, + onDeleteMessage, onAbort, onClientCommand, hasEarlier, @@ -90,6 +92,10 @@ export function Chat({ loadingEarlier?: boolean; onLoadEarlier?: () => void; onSend: (message: string, options?: { voice?: boolean }) => Promise; + /** Replace a sent message: it and everything after it are dropped, and the new text is sent. */ + onEditMessage: (seq: number, message: string) => Promise; + /** Remove a sent message and the agent's answer to it. */ + onDeleteMessage: (seq: number) => Promise; onAbort: () => Promise; /** Builtins the portal itself services — /settings, /new, /name. */ onClientCommand: (name: string, args: string) => void | Promise; @@ -99,6 +105,10 @@ export function Chat({ const [canvasOpen, setCanvasOpen] = useState(false); const [voiceHost, setVoiceHost] = useState(null); const [sending, setSending] = useState(false); + // Which sent message is being rewritten, and what went wrong with the last + // thing done to one — shown in the transcript, where the message is. + const [editing, setEditing] = useState(null); + const [actionError, setActionError] = useState(null); const [panelRequest, setPanelRequest] = useState<"model" | "effort" | null>(null); // Whether there is a browser to watch, and whether you are watching it. Asked // once — the answer only changes when somebody installs or removes one. @@ -261,6 +271,15 @@ export function Chat({ } }; + const attempt = async (fn: () => Promise) => { + setActionError(null); + try { + await fn(); + } catch (e) { + setActionError((e as Error).message); + } + }; + return (
@@ -348,8 +367,24 @@ export function Chat({
); } + if (editing === item.seq) { + return ( +
+ setEditing(null)} + onSave={(next) => + attempt(async () => { + await onEditMessage(item.seq, next); + setEditing(null); + }) + } + /> +
+ ); + } return ( -
+
{item.audio &&
}
{text}
@@ -361,6 +396,36 @@ export function Chat({
)}
+ {/* Only where it can be done: taking a message out from under a + run that is answering it leaves the agent replying to + something that no longer exists. Sending it again is fine — + it just queues, like any other message. */} +
+ attempt(() => onSend(text))} + > + + + setEditing(item.seq)} + > + + + { + if (confirm("Delete this message and the agent's reply to it? The agent forgets it too.")) + attempt(() => onDeleteMessage(item.seq)); + }} + > + + +
); } @@ -430,6 +495,9 @@ export function Chat({ ); })} + {actionError && ( +
{actionError}
+ )} {running && phase && }
@@ -580,6 +648,98 @@ export function Chat({ ); } +function MessageAction({ + label, + onClick, + disabled, + danger, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** A sent message, opened for rewriting in place. */ +function MessageEditor({ + initial, + onSave, + onCancel, +}: { + initial: string; + onSave: (text: string) => Promise; + onCancel: () => void; +}) { + const [value, setValue] = useState(initial); + const [saving, setSaving] = useState(false); + const changed = value.trim() !== initial.trim(); + + const save = async () => { + if (!value.trim() || saving) return; + setSaving(true); + try { + await onSave(value.trim()); + } finally { + setSaving(false); + } + }; + + return ( +
+