diff --git a/src/host/codex-host.mjs b/src/host/codex-host.mjs index 4b32cb4..bee5cf5 100644 --- a/src/host/codex-host.mjs +++ b/src/host/codex-host.mjs @@ -48,7 +48,7 @@ const codexSpawner = createCodexSpawner({ hostDir: HOST_DIR, nodePath: process.e // Bumped on every change the panel needs to know about. Reported in // `agentReady`. Claude's own HOST_VERSION is separate and untouched. -const CODEX_HOST_VERSION = 8; +const CODEX_HOST_VERSION = 9; // The browser bridge numbers its requests from here so the router can tell our // `browserResult` replies from claude's by value alone, and never has to parse @@ -666,6 +666,9 @@ function makeSession(id, cwd) { // is up, rather than being answered with "this session isn't running". opening: true, pending: [], + rewinding: false, + rewindStop: null, + discardedTurns: new Set(), }; } @@ -880,6 +883,15 @@ function sessionFor(params) { function handleNotification(method, params) { const s = sessionFor(params); + const eventTurnId = params?.turnId || params?.turn?.id; + if (s?.discardedTurns.has(eventTurnId)) return; + if (s?.rewinding) { + if (method === "turn/completed" && params.turn?.id === s.turnId) { + s.running = false; + s.rewindStop?.resolve(); + } + return; + } if (s && s.running) touchTurn(s); if (TRACE && method !== "item/agentMessage/delta" && !method.startsWith("mcpServer/")) { const it = params && params.item; @@ -1223,6 +1235,7 @@ function endTurnWith(s, isError, message) { function handleServerRequest(reqId, method, params) { const s = sessionFor(params); + if (s?.rewinding || s?.discardedTurns.has(params?.turnId)) { rpcReplyError(reqId, "turn is being edited"); return; } if (s) touchTurn(s); if (!s) { // No chat owns this thread — refuse rather than leave Codex waiting. @@ -1661,7 +1674,8 @@ async function sendPrompt(msg) { return; } - const input = []; + if (s.rewinding) return; + const input = msg.rewindInput ? [...msg.rewindInput] : []; const text = String(msg.text || ""); if (text) input.push({ type: "text", text }); for (const img of msg.images || []) { @@ -1696,6 +1710,7 @@ async function sendPrompt(msg) { ...turnProfile(s.mode), }); s.turnId = (res && res.turn && res.turn.id) || null; + if (msg.messageId && s.turnId) send({ type: "promptAccepted", id: s.id, messageId: msg.messageId, turnId: s.turnId }); s.running = true; touchTurn(s); } catch (err) { @@ -1730,6 +1745,7 @@ async function interrupt(msg) { function closeSession(id, opts) { const s = sessions.get(id); if (!s) return; + s.rewindStop?.reject(new Error("The chat was closed.")); cancelBrowserWorkflows(s.browserSession); if (s.silenceTimer) clearTimeout(s.silenceTimer); for (const reqId of s.asks.keys()) { @@ -1747,6 +1763,108 @@ function closeSession(id, opts) { if (!opts || !opts.quiet) send({ type: "exit", id, code: 0 }); } +// Stop before changing stored history. An interrupt reply alone does not mean +// the turn has finished writing its last items. +async function stopForRewind(s) { + if (!s.running) return; + if (!s.turnId) throw new Error("Wait for ChatGPT to start, then try again."); + let timer; + const stopped = new Promise((resolve, reject) => { + s.rewindStop = { resolve, reject }; + timer = setTimeout(() => reject(new Error("Couldn't confirm that ChatGPT stopped. Try again once it finishes.")), 20000); + }); + try { + await Promise.all([rpc("turn/interrupt", { threadId: s.threadId, turnId: s.turnId }, 15000), stopped]); + } finally { + clearTimeout(timer); + s.rewindStop = null; + } +} + +async function rewindSession(msg) { + const s = sessions.get(msg.id); + const reply = (extra) => send({ type: "rewindResult", id: msg.id, requestId: msg.requestId, ...extra }); + if (!s?.threadId || s.opening || s.rewinding || !msg.turnId || typeof msg.text !== "string") { + reply({ ok: false, running: !!s?.running, error: "This chat isn't ready to edit. Wait for it to open, then try again." }); + return; + } + s.rewinding = true; + if (s.silenceTimer) { clearTimeout(s.silenceTimer); s.silenceTimer = null; } + try { + cancelBrowserWorkflows(s.browserSession); + await stopForRewind(s); + if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again."); + // Count from the end using stable turn IDs, including unloaded history. + // A panel's visible message index is not a Codex turn index. + let cursor = null, target, removed = []; + const cursors = new Set(); + do { + const page = await transcriptPage({ sessionId: s.threadId, cursor }); + for (const turn of page.turns.slice().reverse()) { + removed.push(turn.id); + if (turn.id === msg.turnId) { target = turn; break; } + } + if (target) break; + cursor = page.nextCursor; + const key = JSON.stringify(cursor); + if (cursor && cursors.has(key)) throw new Error("Couldn't read the chat history. Reopen the chat and try again."); + cursors.add(key); + } while (cursor); + if (!target) throw new Error("This message is no longer in the chat. Reopen the chat and try again."); + const users = (target.items || []).filter((item) => item.type === "userMessage"); + const original = users[0]; + if (!original || (msg.itemId && original.id !== msg.itemId)) { + throw new Error("Edit the first message in this turn to restart it."); + } + // Keep the original page/file context and attachments, also after reload. + const originalText = (original.content || []).filter((part) => part.type === "text").map((part) => part.text).join("\n"); + const context = (originalText.match(/\u200b{3}[\s\S]*?\u200c{3}\n*/g) || []).join(""); + const input = (original.content || []).filter((part) => part.type !== "text"); + if (!msg.text.trim() && !input.length) throw new Error("Enter a message before sending."); + if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again."); + const rollback = await rpc("thread/rollback", { threadId: s.threadId, numTurns: removed.length }, 60000); + if (sessions.get(s.id) !== s) throw new Error("The session changed. Reopen the chat before editing again."); + for (const id of removed) s.discardedTurns.add(id); + for (const reqId of s.asks.keys()) { + send({ type: "permissionCancel", id: s.id, requestId: reqId }); + rpcReplyError(reqId, "turn was edited"); + } + s.asks.clear(); + s.openTools.clear(); + s.execOut.clear(); + s.streamMsgId = null; + s.streamText = ""; + s.planToolId = null; + s.turnId = null; + s.usage = null; + s.running = false; + // The panel only removes old rows after the server confirms the rollback. + reply({ ok: true, previousTurnId: rollback?.thread?.turns?.at(-1)?.id || null }); + s.rewinding = false; + await sendPrompt({ id: s.id, text: context + msg.text, rewindInput: input, messageId: msg.requestId }); + } catch (err) { + reply({ ok: false, running: s.running, error: `Couldn't edit this message: ${err?.message || err}` }); + } finally { + s.rewinding = false; + if (s.running) touchTurn(s); + else { + // A failed rollback can still follow a successful stop. Retire requests + // and stream state from that stopped turn before another prompt arrives. + for (const reqId of s.asks.keys()) { + send({ type: "permissionCancel", id: s.id, requestId: reqId }); + rpcReplyError(reqId, "turn ended"); + } + s.asks.clear(); + s.openTools.clear(); + s.execOut.clear(); + s.streamMsgId = null; + s.streamText = ""; + s.planToolId = null; + s.turnId = null; + } + } +} + async function restartSession(msg) { const s = sessions.get(msg.id); if (!s) return; @@ -1792,8 +1910,9 @@ async function transcriptPage(msg) { } const res = await rpc("thread/read", { threadId, includeTurns: true }, 60000); const turns = res?.thread?.turns || []; - const end = msg.cursor?.kind === "legacy" ? turns.findIndex((t) => t.id === msg.cursor.before) : turns.length; - if (end < 0) throw new Error("History changed. Reopen the chat to reload it."); + const boundary = msg.cursor?.kind === "legacy" ? turns.findIndex((t) => t.id === (msg.cursor.through || msg.cursor.before)) : turns.length; + if (boundary < 0) throw new Error("History changed. Reopen the chat to reload it."); + const end = msg.cursor?.through ? boundary + 1 : boundary; const start = Math.max(0, end - 5); return { turns: turns.slice(start, end), nextCursor: start > 0 ? { kind: "legacy", before: turns[start].id } : null }; } @@ -1829,7 +1948,7 @@ async function loadTranscript(msg) { for (const turn of page.turns) { for (const item of turn.items || []) { const replay = replayItem(s, item); - if (replay) events.push(...replay.map((event) => ({ ...event, historyItemId: item.id, + if (replay) events.push(...replay.map((event) => ({ ...event, historyItemId: item.id, historyTurnId: turn.id, timestamp: turn.startedAt ? new Date(turn.startedAt * 1000).toISOString() : undefined }))); } } @@ -1846,8 +1965,11 @@ function replayItem(s, item) { switch (item.type) { case "userMessage": { const text = (item.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n"); - if (!text) return null; - return [{ type: "user", message: { role: "user", content: [{ type: "text", text }] } }]; + const hasAttachments = (item.content || []).some((part) => part.type !== "text"); + if (!text && !hasAttachments) return null; + const attachments = (item.content || []).filter((part) => part.type === "image" && /^data:image\//.test(part.url || "")) + .map((part) => ({ dataUrl: part.url, mediaType: part.url.slice(5).split(";")[0] })); + return [{ type: "user", hasAttachments, attachments, message: { role: "user", content: [{ type: "text", text }] } }]; } case "agentMessage": if (!item.text) return null; @@ -1895,7 +2017,7 @@ function handle(msg) { case "listSkills": startAppServer().then(() => loadSkills({ id: msg.id, cwd: msg.cwd || homedir() })).catch((err) => send({ type: "commands", id: msg.id, agent: "codex", cwd: msg.cwd, list: [], skills: [], error: err.message })); break; - case "rewind": send({ type: "error", id: msg.id, message: "Editing past messages is not supported in ChatGPT chats." }); break; + case "rewind": rewindSession(msg); break; case "remoteControl": send({ type: "remoteControl", id: msg.id, ok: false, error: "Remote Control is only available in Claude Code chats." }); break; case "authCode": send({ type: "authDone", id: msg.id, ok: false, message: "Complete sign-in on the ChatGPT sign-in page." }); break; case "authLogin": startLogin(msg.id); break; diff --git a/src/panel/chat.js b/src/panel/chat.js index 7c49415..37dfb8c 100644 --- a/src/panel/chat.js +++ b/src/panel/chat.js @@ -2117,17 +2117,135 @@ // synthetic "/login" one) pass no opts and stay plain. function userBubble(chat, text, attachments, opts) { const row = el("div", "msg msg-user"); - const bubble = buildBubble(text, attachments, opts && opts.contexts); + const bubble = buildBubble(text || (opts?.hasAttachments && !attachments?.length ? "_(attached file)_" : ""), attachments, opts && opts.contexts); row.appendChild(bubble); if (opts && opts.real && !chat.historyPage) { const turnIndex = ++chat.turnIndexCounter; row.dataset.turnIndex = String(turnIndex); if (chat.harness !== "codex") wireEditableBubble(chat, bubble, turnIndex, text, attachments); } + if (opts?.real && chat.harness === "codex") { + row.dataset.messageId = opts.messageId || ""; + row.codexEdit = { text, attachments, hasAttachments: opts.hasAttachments || !!attachments?.length, contexts: opts.contexts, turnId: opts.turnId, itemId: opts.itemId }; + if (opts.turnId) wireCodexEdit(chat.historyOwner || chat, row); + } append(chat, row); return row; } + function wireCodexEdit(chat, row) { + if (row.codexEditWired || !row.codexEdit?.turnId) return; + row.codexEditWired = true; + const button = el("button", "sent-edit"); + button.type = "button"; + button.title = "Edit message"; + button.setAttribute("aria-label", "Edit message"); + button.innerHTML = ICON("edit", 13); + button.addEventListener("click", () => beginCodexEdit(chat, row)); + row.appendChild(button); + const bubble = row.querySelector(".bubble"); + bubble.classList.add("editable"); + bubble.addEventListener("click", (event) => { + if (event.target.closest("a, button, textarea") || String(window.getSelection() || "")) return; + beginCodexEdit(chat, row); + }); + } + + function acceptCodexPrompt(chat, msg) { + const row = [...chat.messagesEl.children].find((node) => node.dataset.messageId === msg.messageId); + if (!row?.codexEdit) return; + row.codexEdit.turnId = msg.turnId; + wireCodexEdit(chat, row); + } + + function beginCodexEdit(chat, row) { + const bubble = row.querySelector(".bubble"); + if (chat.rewindPending || bubble.classList.contains("editing")) return; + const edit = row.codexEdit; + if (!edit?.turnId) return; + const md = bubble.querySelector(".md"); + const input = el("textarea", "msg-edit"); + input.setAttribute("aria-label", "Edit message"); + input.value = edit.text; + if (md) md.replaceWith(input); else bubble.appendChild(input); + bubble.classList.add("editing"); + const note = el("div", "msg-edit-note", "Sending restarts the chat from here. Later messages are removed. File changes stay."); + const actions = el("div", "msg-edit-actions"); + const cancel = el("button", "msg-edit-cancel", "Cancel"); + const save = el("button", "msg-edit-save", "Save & send"); + cancel.type = save.type = "button"; + actions.append(cancel, save); + bubble.append(note, actions); + const resize = () => { input.style.height = "auto"; input.style.height = input.scrollHeight + "px"; }; + input.addEventListener("input", resize); + const setPending = (pending) => { + input.disabled = cancel.disabled = save.disabled = pending; + save.textContent = pending ? "Restarting…" : "Save & send"; + }; + const revert = () => { + if (input.disabled) return; + input.replaceWith(R.markdown(edit.text)); + note.remove(); actions.remove(); bubble.classList.remove("editing"); + }; + const submit = () => { + if (chat.rewindPending || input.disabled) return; + if (!input.value.trim() && !edit.hasAttachments) return; + const requestId = newId(); + const pending = { requestId, row, text: input.value, edit, setPending }; + chat.rewindPending = pending; + setPending(true); + if (!post({ type: "rewind", id: chat.id, requestId, turnId: edit.turnId, itemId: edit.itemId, text: input.value })) { + finishCodexEdit(chat, { requestId, ok: false, running: chat.turnRunning, error: "Host disconnected. Your edit was not sent." }); + } + }; + cancel.addEventListener("click", revert); + save.addEventListener("click", submit); + input.addEventListener("keydown", (event) => { + if (event.isComposing) return; + if (event.key === "Escape") { event.preventDefault(); revert(); } + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submit(); } + }); + resize(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); + } + + function finishCodexEdit(chat, msg) { + const pending = chat.rewindPending; + if (!pending || pending.requestId !== msg.requestId) return; + chat.rewindPending = null; + if (!msg.ok) { + pending.setPending(false); + systemNote(chat, msg.error || "Couldn't edit this message. Try again.", "warn"); + if (!msg.running && chat.turnRunning) { + // Keep queued prompts in place while the user corrects or retries an edit. + chat.rewindPending = pending; + endTurn(chat, null); + chat.rewindPending = null; + } + return; + } + if (chat.historyRequest) { + clearTimeout(chat.historyRequest.timer); + chat.historyRequest = null; + } + chat.historyError = false; + if (chat.historyCursor) { + // The old cursor may name a removed turn. Anchor it to retained history. + const rows = [...chat.messagesEl.children]; + const earlier = rows.slice(0, rows.indexOf(pending.row)).find((row) => row.codexEdit?.turnId); + chat.historyCursor = earlier ? { kind: "legacy", before: earlier.codexEdit.turnId } + : msg.previousTurnId ? { kind: "legacy", through: msg.previousTurnId } : null; + } + historyNav(chat); + // Keep queued prompts, but remove their rows while the transcript is cut. + for (const entry of chat.queue || []) entry.el?.remove(); + resendEdited(chat, pending.row.dataset.turnIndex, pending.text, pending.edit.attachments, pending); + for (const entry of chat.queue || []) entry.el = renderQueuedBubble(chat, entry); + chat.lastSentPrompt = { text: pending.text, attachments: (pending.edit.attachments || []).slice() }; + chat.codexHasSubmittedTurn = true; + chat.ctxBase = 0; chat.ctxTokens = 0; + touchChat(chat); savePrefs(); + } + // Click the message text to edit it in place. Committing (Enter) rewinds // the conversation back to just before this message — both the rendered // transcript and the real session — and resends the edited text as the @@ -2196,18 +2314,18 @@ // sent right after would race that gap. So the host does the whole thing — // truncate, resume, write the edited prompt to the new process — as one // atomic step (see rewindSession in claude-host.mjs). - function resendEdited(chat, turnIndex, newText, attachments) { - if (chat.harness === "codex") return; + function resendEdited(chat, turnIndex, newText, attachments, confirmed) { + if (chat.harness === "codex" && !confirmed) return; const rows = Array.from(chat.messagesEl.children); - const startIdx = rows.findIndex((r) => r.dataset && r.dataset.turnIndex === String(turnIndex)); + const startIdx = confirmed ? rows.indexOf(confirmed.row) : rows.findIndex((r) => r.dataset && r.dataset.turnIndex === String(turnIndex)); if (startIdx === -1) return; const images = (attachments || []).map((a) => ({ mediaType: a.mediaType, data: (a.dataUrl.split(",")[1] || "") })); - if (!post({ type: "rewind", id: chat.id, turnIndex, text: newText, images })) { + if (!confirmed && !post({ type: "rewind", id: chat.id, turnIndex, text: newText, images })) { systemNote(chat, "Host disconnected — couldn't rewind. Nothing was changed.", "warn"); return; } for (let i = rows.length - 1; i >= startIdx; i--) rows[i].remove(); - chat.turnIndexCounter = turnIndex - 1; + if (!confirmed) chat.turnIndexCounter = turnIndex - 1; chat.currentAssistantId = null; chat.currentAssistantBody = null; // Kill any typewriter loops first — a live one would re-append the row @@ -2230,7 +2348,7 @@ chat.emittedToolIds.delete(toolUseId); } } - userBubble(chat, newText, attachments, { real: true }); + userBubble(chat, newText, attachments, { real: true, messageId: confirmed?.requestId, contexts: confirmed?.edit.contexts, hasAttachments: confirmed?.edit.hasAttachments }); chat.turnRunning = true; chat.unseen = false; updateTabDots(); @@ -5115,7 +5233,7 @@ // A model/mode/effort switch made mid-turn was deferred so it wouldn't // hard-kill the reply that was still streaming — apply it now that the // turn is actually done, before anything queued goes out under it. - if (chat.restartPending) restartSessionNow(chat); + if (chat.restartPending && !chat.rewindPending) restartSessionNow(chat); // The turn may have edited files — refresh the uncommitted-changes badge. // It may also have switched or created a branch, so re-ask for that too: // the git bar always names the current branch on its left. @@ -5381,6 +5499,8 @@ codexUsage.fetching = false; for (const c of chats.values()) { c.started = false; + if (c.rewindPending) finishCodexEdit(c, { requestId: c.rewindPending.requestId, ok: false, running: c.turnRunning, + error: "Host disconnected while editing. Reopen the chat to check its history before trying again." }); if (c.historyRequest) historyFailed(c, c.historyRequest); if (c.turnRunning) { systemNote(c, "Host disconnected mid-turn.", "warn"); @@ -5582,6 +5702,12 @@ } } break; + case "promptAccepted": + if (chat) acceptCodexPrompt(chat, msg); + break; + case "rewindResult": + if (chat) finishCodexEdit(chat, msg); + break; case "event": if (chat) onClaudeEvent(chat, msg.data); break; @@ -5651,6 +5777,8 @@ if (c.harness !== msg.agent) continue; if (c.historyRequest) historyFailed(c, c.historyRequest); c.started = false; + if (c.rewindPending) finishCodexEdit(c, { requestId: c.rewindPending.requestId, ok: false, running: c.turnRunning, + error: "The ChatGPT helper stopped while editing. Reopen the chat to check its history." }); clearPermCards(c); if (c.turnRunning) { systemNote(c, `${harnessLabel(msg.agent)} stopped.`, "warn"); @@ -5879,6 +6007,10 @@ break; case "error": if (chat) { + if (chat.rewindPending) { + finishCodexEdit(chat, { requestId: chat.rewindPending.requestId, ok: false, running: chat.turnRunning, error: msg.message }); + break; + } liftSuppress(chat); // a respawn error must not leave the event gate shut if (isAuthRevokedError(msg.message)) { // The exit event right behind this one would otherwise print a @@ -6043,6 +6175,7 @@ // reply, its counters, or its pending requests. const page = makeChat({ id: "history-" + chat.id, harness: chat.harness, model: chat.model, cwd: chat.cwd }); page.historyPage = true; + page.historyOwner = chat; const seen = new Set(); const events = msg.events.filter((event) => { if (!event.historyItemId) return true; @@ -6105,6 +6238,13 @@ if (!ev.message) continue; if (ev.type === "user") { const content = ev.message.content; + if (chat.harness === "codex" && ev.historyTurnId && !content?.some?.((part) => part.type === "tool_result")) { + const raw = typeof content === "string" ? content : (content || []).filter((part) => part.type === "text").map((part) => part.text).join("\n"); + const text = raw.replace(CTX_MARK_RE, "").trim(); + if (text || ev.hasAttachments) userBubble(chat, text, ev.attachments, { real: true, + turnId: ev.historyTurnId, itemId: ev.historyItemId, hasAttachments: ev.hasAttachments }); + continue; + } const ts = ev.timestamp ? Date.parse(ev.timestamp) || Date.now() : Date.now(); if (typeof content === "string") { // A background task / async subagent completion notice is a synthetic @@ -6123,7 +6263,7 @@ commandBubbleText(content) || content.replace(SYNTHETIC_USER_TAG_RE, "").replace(CTX_MARK_RE, "").trim(); // Don't replay bare `/usage` command bubbles (their output is skipped // above, so the lone command echo would dangle). - if (stripped && !USAGE_CMD_RE.test(stripped)) userBubble(chat, stripped, null, { real: true, ts }); + if (stripped && !USAGE_CMD_RE.test(stripped)) userBubble(chat, stripped, null, { real: true, ts, turnId: ev.historyTurnId, itemId: ev.historyItemId }); } else if (Array.isArray(content)) { const texts = []; for (const b of content) { @@ -6140,7 +6280,7 @@ const joined = texts.join("\n\n"); const stripped = commandBubbleText(joined) || joined.replace(SYNTHETIC_USER_TAG_RE, "").replace(CTX_MARK_RE, "").trim(); - if (stripped && !USAGE_CMD_RE.test(stripped)) userBubble(chat, stripped, null, { real: true, ts }); + if (stripped && !USAGE_CMD_RE.test(stripped)) userBubble(chat, stripped, null, { real: true, ts, turnId: ev.historyTurnId, itemId: ev.historyItemId }); } } else if (ev.type === "assistant") { // Each replayed assistant message refreshes the context reading; the @@ -6669,7 +6809,7 @@ // Drains the next queued prompt (if any) once a turn finishes. Runs even if // `chat` isn't the active tab — background chats keep working while queued. function dispatchNextQueued(chat) { - if (chat.sessionFailure) return; + if (chat.sessionFailure || chat.rewindPending) return; if (!Array.isArray(chat.queue) || !chat.queue.length) return; // Head of the queue is open for editing — hold everything until the user // is done with it (or deletes it); both paths call back in here. @@ -6690,6 +6830,13 @@ // sessionLooksStale). Respawn it first so it re-reads the current keychain // credentials — resume keeps the conversation — and queue the prompt to go // out at the fresh process's init instead of dying with a 401. + if (chat.rewindPending) { + if (!Array.isArray(chat.queue)) chat.queue = []; + const entry = { text, contexts: contexts.slice(), attachments: attachments.slice(), silent }; + chat.queue.push(entry); + entry.el = renderQueuedBubble(chat, entry); + return; + } if (sessionLooksStale(chat)) { chat.restartFlush = true; if (!Array.isArray(chat.queue)) chat.queue = []; @@ -6762,7 +6909,8 @@ // reached the host. Requeue the prompt (visible, cancellable) instead of // entering a running state whose spinner would never stop — endTurn() or // the reconnect's `ready` handler re-delivers it. - if (!post({ type: "prompt", id: chat.id, text: sentText, images })) { + const messageId = chat.harness === "codex" ? newId() : undefined; + if (!post({ type: "prompt", id: chat.id, text: sentText, images, messageId })) { const entry = { text, contexts: contexts.slice(), @@ -6793,6 +6941,7 @@ if (silent) chat.turnIndexCounter++; else userBubble(chat, text || (hasContext ? bubbleHint : ""), attachments, USAGE_CMD_RE.test(text) ? null : { real: true, + messageId, contexts: !isCommand && hasContext ? contexts.slice() : null, // command turns don't consume chips }); if (!isCommand) { diff --git a/src/panel/panel.css b/src/panel/panel.css index 4e36a1e..0ff3d68 100644 --- a/src/panel/panel.css +++ b/src/panel/panel.css @@ -4602,3 +4602,25 @@ button.model-item:focus-visible, button.mode-item:focus-visible, button.branch-i outline: 2px solid currentColor; outline-offset: -2px; } .login-device-code { font: 600 20px monospace; letter-spacing: 0.12em; user-select: all; margin: 12px 0; } + +/* Sent ChatGPT messages can restart their turn, including loaded history. */ +.sent-edit { + align-self: flex-end; + flex-shrink: 0; + border: 0; + border-radius: 6px; + padding: 5px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + opacity: 0; +} +.msg-user:hover .sent-edit, .msg-user:focus-within .sent-edit { opacity: 1; } +.sent-edit:hover, .sent-edit:focus-visible { color: var(--text-primary); background: var(--control-secondary); } +.msg-edit-note { margin-top: 10px; color: var(--text-secondary); font-size: 11px; line-height: 1.5; } +.msg-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } +.msg-edit-actions button { border: 1px solid var(--border-primary); border-radius: 6px; padding: 5px 10px; cursor: pointer; font: inherit; font-size: 12px; } +.msg-edit-cancel { background: transparent; color: var(--text-primary); } +.msg-edit-save { background: var(--text-primary); color: var(--bg-primary); } +.msg-edit-actions button:disabled { opacity: .5; cursor: default; } +@media (hover: none) { .sent-edit { opacity: 1; } } diff --git a/test/e2e/edit-panel.js b/test/e2e/edit-panel.js new file mode 100644 index 0000000..c7df1a6 --- /dev/null +++ b/test/e2e/edit-panel.js @@ -0,0 +1,89 @@ +"use strict"; + +window.runEditPanelTests = async function () { + const t = window.__test, checks = []; + const check = (name, ok) => { if (!ok) throw new Error(name); checks.push(name); }; + const pause = () => new Promise((resolve) => setTimeout(resolve, 0)); + const emit = (msg) => t.emit({ id: "edit-a", ...msg }); + const event = (data) => emit({ type: "event", data }); + const result = () => event({ type: "result", subtype: "success", usage: {} }); + const user = (n) => ({ type: "user", historyTurnId: "turn-" + n, historyItemId: "user-" + n, + message: { content: [{ type: "text", text: "Question " + n }] } }); + const answer = (n) => ({ type: "assistant", historyTurnId: "turn-" + n, historyItemId: "answer-" + n, + message: { id: "answer-" + n, content: [{ type: "text", text: "Answer " + n }], usage: {} } }); + const history = (request, events, nextCursor) => emit({ type: "transcript", paged: true, done: true, + sessionId: request.sessionId, requestId: request.requestId, events, nextCursor }); + t.emit({ type: "ready", ok: true, version: 33, home: "/test", user: "Test" }); + t.emit({ type: "agentReady", agent: "codex", ok: true, version: 8 }); + emit({ type: "started", cwd: "/test/project", permissionMode: "workspace" }); + event({ type: "system", subtype: "init", agent: "codex", session_id: "thread-edit", model: "test-model", cwd: "/test/project" }); + history(t.posted("loadTranscript").at(-1), [user(2), answer(2), user(3), answer(3)], { kind: "turns", value: "before-2" }); + const box = document.querySelector(".chat-messages:not(.hidden)"); + const first = () => box.querySelector(".sent-edit"); + check("loaded user messages have edit buttons", box.querySelectorAll(".sent-edit").length === 2); + first().click(); + let input = box.querySelector(".msg-edit"); + check("editing opens the original text", input.value === "Question 2"); + input.value = "Cancelled"; + box.querySelector(".msg-edit-cancel").click(); + check("Cancel keeps the original history and sends nothing", !box.querySelector(".msg-edit") && box.textContent.includes("Question 2") && !t.posted("rewind").length); + first().click(); input = box.querySelector(".msg-edit"); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true })); + check("Shift+Enter and IME confirmation do not send", !t.posted("rewind").length); + input.value = "Changed question"; + box.querySelector(".msg-edit-save").click(); + const failed = t.posted("rewind").at(-1); + check("edit uses the stored turn ID and live chat ID", failed.id === "edit-a" && failed.turnId === "turn-2" && failed.itemId === "user-2" && failed.agent === "codex"); + check("history stays visible until the host confirms", box.textContent.includes("Answer 3") && input.disabled); + emit({ type: "rewindResult", requestId: "stale", ok: true }); + check("unrelated acknowledgements cannot cut history", box.textContent.includes("Answer 3")); + emit({ type: "rewindResult", requestId: failed.requestId, ok: false, running: false, error: "Test rollback failure" }); + check("failed edits keep the draft and later replies", !input.disabled && input.value === "Changed question" && box.textContent.includes("Answer 3")); + box.querySelector(".msg-edit-save").click(); + const saved = t.posted("rewind").at(-1); + box.querySelector(".msg-edit-save").click(); + check("double clicks send a single request", t.posted("rewind").length === 2); + emit({ type: "rewindResult", requestId: saved.requestId, ok: true, previousTurnId: "turn-1" }); + check("confirmed edits replace the selected message and later replies", box.textContent.includes("Changed question") && !box.textContent.includes("Answer 3") && !box.textContent.includes("Question 2")); + emit({ type: "promptAccepted", messageId: saved.requestId, turnId: "replacement" }); + check("the replacement can be edited again", box.querySelectorAll(".sent-edit").length === 1); + event({ type: "assistant", message: { id: "replacement-reply", content: [{ type: "text", text: "New answer" }], usage: {} } }); result(); + box.querySelector(".history-more").click(); + check("editing the first loaded turn keeps older history reachable", t.posted("loadTranscript").at(-1).cursor.through === "turn-1"); + history(t.posted("loadTranscript").at(-1), [user(1), answer(1)], null); + first().click(); input = box.querySelector(".msg-edit"); input.value = "Earlier edit"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + const older = t.posted("rewind").at(-1); + check("an older page edits the real chat rather than its temporary renderer", older.id === "edit-a" && older.turnId === "turn-1"); + emit({ type: "rewindResult", requestId: older.requestId, ok: false, running: false, error: "Keep for test" }); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + const composer = document.querySelector("#composer-input"); + composer.value = "Live question"; composer.dispatchEvent(new Event("input", { bubbles: true })); + composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await pause(); + const prompt = t.posted("prompt").at(-1); + check("new prompts have a correlation ID", !!prompt.messageId && prompt.text.includes("Live question")); + emit({ type: "promptAccepted", messageId: prompt.messageId, turnId: "live-turn" }); + const liveRow = [...box.querySelectorAll(".msg-user")].find((row) => row.textContent.includes("Live question")); + liveRow.querySelector(".sent-edit").click(); + liveRow.querySelector(".msg-edit").value = "Live correction"; + liveRow.querySelector(".msg-edit-save").click(); + const liveEdit = t.posted("rewind").at(-1); + check("running messages can restart their own turn", liveEdit.turnId === "live-turn"); + composer.value = "Queued after edit"; composer.dispatchEvent(new Event("input", { bubbles: true })); + composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); await pause(); + check("prompts submitted during an edit wait in the queue", t.posted("prompt").length === 1); + emit({ type: "rewindResult", requestId: liveEdit.requestId, ok: true }); + emit({ type: "promptAccepted", messageId: liveEdit.requestId, turnId: "live-replacement" }); + result(); await pause(); + check("queued prompts resume once after the replacement finishes", t.posted("prompt").length === 2 && t.posted("prompt").at(-1).text.includes("Queued after edit")); + result(); + check("the panel reports no runtime errors", t.errors.length === 0); + first().click(); + input = box.querySelector(".msg-edit"); input.value = "Keep this edit after disconnect"; + box.querySelector(".msg-edit-save").click(); + t.disconnect(); + check("disconnect unlocks the editor and preserves its text", !input.disabled && input.value === "Keep this edit after disconnect" && box.textContent.includes("Answer 1")); + check("disconnect causes no browser errors", t.errors.length === 0); + return { passed: checks.length, checks }; +}; diff --git a/test/e2e/panel.html b/test/e2e/panel.html index 3e0d4f9..cc59430 100644 --- a/test/e2e/panel.html +++ b/test/e2e/panel.html @@ -176,6 +176,11 @@ activeId: "failure-a", lastHarness: "codex", tabs: [{ id: "failure-a", title: "New chat", harness: "codex", cwd: "/test/project", mode: "workspace" }], }; + const editRegression = new URLSearchParams(location.search).get("regressions") === "edit"; + if (editRegression) store.rkChatV2 = { + activeId: "edit-a", lastHarness: "codex", + tabs: [{ id: "edit-a", title: "Edit messages", harness: "codex", model: "test-model", cwd: "/test/project", sessionId: "thread-edit", mode: "workspace" }], + }; const historyRegression = new URLSearchParams(location.search).get("regressions") === "history"; const auditRegression = new URLSearchParams(location.search).get("regressions") === "audit"; const codexRegression = auditRegression || new URLSearchParams(location.search).get("regressions") === "codex"; @@ -234,6 +239,15 @@ }); window.__test.sessionFailureResult = await window.runSessionFailurePanelTests(); } + if (editRegression) { + await new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "edit-panel.js?t=" + stamp; + script.onload = resolve; script.onerror = reject; + document.body.appendChild(script); + }); + window.__test.editResult = await window.runEditPanelTests(); + } if (codexRegression || historyRegression) { await new Promise((resolve, reject) => { const script = document.createElement("script"); diff --git a/test/unit/codex-host.test.mjs b/test/unit/codex-host.test.mjs index eac3b17..2e6ba20 100644 --- a/test/unit/codex-host.test.mjs +++ b/test/unit/codex-host.test.mjs @@ -34,7 +34,7 @@ async function host() { clearTimeout: (timer) => timers.delete(timer), }); const source = readFileSync(new URL("../../src/host/codex-host.mjs", import.meta.url), "utf8"); - const module = new SourceTextModule(source + `\nexport { browserClients, cancelBrowserWorkflows, handle, loadSkills, loadTranscript, sendTranscriptPage, MODELS, effortForModel, app, sessions, byThread, makeSession, usageBlock, handleNotification, handleServerRequest, answerPermission, onAppMessage, startSession, restartSession, sendPrompt, interrupt, browserRequest, browserMcpConfig, resolveBrowser, runPrewarm, takePrewarmed, ensureProviderKey, refreshPlanUsage, closeSession };`, { context }); + const module = new SourceTextModule(source + `\nexport { browserClients, cancelBrowserWorkflows, handle, loadSkills, loadTranscript, sendTranscriptPage, MODELS, effortForModel, app, sessions, byThread, makeSession, usageBlock, handleNotification, handleServerRequest, answerPermission, onAppMessage, startSession, restartSession, rewindSession, sendPrompt, interrupt, browserRequest, browserMcpConfig, resolveBrowser, runPrewarm, takePrewarmed, ensureProviderKey, refreshPlanUsage, closeSession };`, { context }); await module.link((name) => { const values = imports[name]; assert.ok(values, `unexpected import: ${name}`); @@ -336,7 +336,7 @@ test("older CLI history fallback pages without offset drift or swallowed failure test("unsupported Codex actions return a failure instead of silently hanging", async () => { const h = await host(); h.session(); - for (const [type, response] of [["rewind", "error"], ["remoteControl", "remoteControl"], ["authCode", "authDone"]]) { + for (const [type, response] of [["rewind", "rewindResult"], ["remoteControl", "remoteControl"], ["authCode", "authDone"]]) { const before = h.messages.length; h.api.handle({ type, id: "a", code: "test-only", text: "changed" }); assert.equal(h.messages.length, before + 1); assert.equal(h.messages.at(-1).type, response); @@ -428,3 +428,142 @@ test("stopping or closing a chat cancels only its browser workflows", async () = h.api.closeSession("b"); assert.equal(receivedB[0].type, "workflowCancel"); }); + +const editTurn = (id, content = [{ type: "text", text: id }]) => ({ id, items: [{ id: `user-${id}`, type: "userMessage", content }] }); +const editRequest = (turnId = "t2") => ({ id: "a", requestId: "edit-request", turnId, itemId: `user-${turnId}`, text: "Changed prompt" }); + +test("editing uses stable IDs across history pages and preserves context and original attachments", async () => { + const h = await host(), s = h.session(); s.running = false; + const context = "\u200b\u200b\u200bPage context\u200c\u200c\u200c"; + const attachments = [{ type: "image", url: "data:image/png;base64,abc" }, { type: "localImage", path: "/test/image.png" }]; + h.respond((req) => { + if (req.method === "thread/turns/list") return req.params.cursor + ? { data: [editTurn("t2", [{ type: "text", text: context + "Old" }, ...attachments]), editTurn("t1")] } + : { data: [editTurn("t4"), editTurn("t3")], nextCursor: "older" }; + if (req.method === "thread/rollback") { assert.equal(req.params.numTurns, 3); return { thread: { id: s.threadId } }; } + if (req.method === "turn/start") { + assert.equal(h.messages.at(-1).type, "rewindResult"); + assert.equal(h.messages.at(-1).ok, true); + assert.deepEqual(req.params.input, [...attachments, { type: "text", text: context + "Changed prompt" }]); + return { turn: { id: "replacement" } }; + } + throw new Error(req.method); + }); + await h.api.rewindSession(editRequest()); + assert.deepEqual(h.requests.map((r) => r.method), ["thread/turns/list", "thread/turns/list", "thread/rollback", "turn/start"]); + assert.equal(h.messages.at(-1).type, "promptAccepted"); + assert.equal(h.messages.at(-1).turnId, "replacement"); + assert.equal(h.messages.at(-1).messageId, "edit-request"); + assert.equal(s.running, true); + h.api.handleNotification("turn/completed", { threadId: s.threadId, turn: { id: "t4", status: "completed" } }); + assert.equal(s.running, true); +}); + +test("editing an active chat waits for turn completion and ignores late removed events", async () => { + const h = await host(), s = h.session(); + h.respond((req) => { + if (req.method === "turn/interrupt") return {}; + if (req.method === "thread/turns/list") return { data: [editTurn("turn-a"), editTurn("t2")] }; + if (req.method === "turn/start") return { turn: { id: "new-turn" } }; + return {}; + }); + const work = h.api.rewindSession(editRequest()); + await new Promise(setImmediate); + assert.deepEqual(h.requests.map((r) => r.method), ["turn/interrupt"]); + const before = h.messages.length; + h.api.handleNotification("item/started", { threadId: s.threadId, turnId: "turn-a", item: { type: "agentMessage", id: "stale" } }); + assert.equal(h.messages.length, before); + h.api.handleNotification("turn/completed", { threadId: s.threadId, turn: { id: "turn-a", status: "interrupted" } }); + await work; + const count = h.messages.length; + h.api.handleNotification("item/completed", { threadId: s.threadId, turnId: "turn-a", item: { type: "agentMessage", id: "stale", text: "Stale reply" } }); + assert.equal(h.messages.length, count); + assert.equal(s.turnId, "new-turn"); + assert.equal(s.rewinding, false); +}); + +for (const failure of ["missing target", "wrong user item", "rollback unsupported", "read failed"]) { + test(`failed edit never starts a replacement: ${failure}`, async () => { + const h = await host(), s = h.session(); s.running = false; + h.respond((req) => { + if (req.method === "thread/turns/list") { + if (failure === "read failed") throw new Error("Disk read failed"); + return { data: [editTurn(failure === "missing target" ? "other" : "t2")] }; + } + if (req.method === "thread/rollback") throw new Error("method not found"); + throw new Error("Must not start a turn"); + }); + const msg = editRequest(); if (failure === "wrong user item") msg.itemId = "later-correction"; + await h.api.rewindSession(msg); + assert.equal(h.messages.at(-1).type, "rewindResult"); + assert.equal(h.messages.at(-1).ok, false); + assert.equal(h.requests.some((r) => r.method === "turn/start"), false); + assert.equal(s.rewinding, false); + }); +} + +test("first-message edit removes every turn and can restart with only an attachment", async () => { + const h = await host(), s = h.session(); s.running = false; + h.respond((req) => { + if (req.method === "thread/turns/list") return { data: [editTurn("t2"), editTurn("t1", [{ type: "localImage", path: "/test/one.png" }])] }; + if (req.method === "thread/rollback") assert.equal(req.params.numTurns, 2); + if (req.method === "turn/start") { assert.deepEqual(req.params.input, [{ type: "localImage", path: "/test/one.png" }]); return { turn: { id: "new" } }; } + return {}; + }); + await h.api.rewindSession({ ...editRequest("t1"), text: "" }); + assert.equal(h.messages.find((m) => m.type === "rewindResult").ok, true); +}); + +test("double submission cannot roll back twice and a stop timeout leaves history intact", async () => { + const h = await host(); h.session(); + h.respond(() => ({})); + const work = h.api.rewindSession(editRequest()); + await h.api.rewindSession({ ...editRequest(), requestId: "duplicate" }); + assert.equal(h.messages.at(-1).ok, false); + const timeout = [...h.timers].find((t) => t.ms === 20000); assert.ok(timeout); timeout.fn(); + await work; + assert.equal(h.messages.at(-1).ok, false); + assert.equal(h.requests.some((r) => r.method === "thread/rollback"), false); +}); + +test("history carries the stable turn and user-item IDs used by edits", async () => { + const h = await host(); h.session(); + h.respond(() => ({ data: [editTurn("t2")] })); + await h.api.loadTranscript({ id: "a", sessionId: "thread-a", requestId: "history" }); + assert.equal(h.messages.at(-1).events[0].historyTurnId, "t2"); + assert.equal(h.messages.at(-1).events[0].historyItemId, "user-t2"); +}); + +test("after editing the first loaded message, older history stops at a retained turn", async () => { + const h = await host(); + h.respond((req) => { assert.equal(req.method, "thread/read"); return { thread: { turns: [editTurn("old"), editTurn("retained"), editTurn("replacement")] } }; }); + await h.api.loadTranscript({ id: "a", sessionId: "thread-a", requestId: "history", cursor: { kind: "legacy", through: "retained" } }); + assert.deepEqual(h.messages.at(-1).events.map((e) => e.historyTurnId), ["old", "retained"]); +}); + +test("image-only history retains its editable target and attachments", async () => { + const h = await host(); + h.respond(() => ({ data: [editTurn("image", [{ type: "image", url: "data:image/png;base64,abc" }])] })); + await h.api.loadTranscript({ id: "a", sessionId: "thread-a", requestId: "history" }); + const event = h.messages.at(-1).events[0]; + assert.equal(event.historyTurnId, "image"); + assert.equal(event.hasAttachments, true); + assert.equal(event.attachments[0].dataUrl, "data:image/png;base64,abc"); +}); + +test("a failed rollback after stopping retires stale approval and stream state", async () => { + const h = await host(), s = h.session(); + s.asks.set(71, { kind: "command", params: {} }); s.streamMsgId = "stale"; s.openTools.add("tool"); + h.respond((req) => { + if (req.method === "turn/interrupt") { + h.api.handleNotification("turn/completed", { threadId: s.threadId, turn: { id: "turn-a", status: "interrupted" } }); + return {}; + } + if (req.method === "thread/turns/list") return { data: [editTurn("t2")] }; + throw new Error("rollback failed"); + }); + await h.api.rewindSession(editRequest()); + assert.equal(s.running, false); assert.equal(s.asks.size, 0); assert.equal(s.openTools.size, 0); + assert.equal(s.streamMsgId, null); assert.equal(s.turnId, null); + assert.equal(h.messages.find((m) => m.type === "rewindResult").ok, false); +});