diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 7dcf840d..0cc89c16 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -13,6 +13,7 @@ import type { ThreadArchiveResponse } from "../generated/app-server/v2/ThreadArc import type { ThreadForkResponse } from "../generated/app-server/v2/ThreadForkResponse"; import type { ThreadListResponse } from "../generated/app-server/v2/ThreadListResponse"; import type { ThreadResumeResponse } from "../generated/app-server/v2/ThreadResumeResponse"; +import type { ThreadRollbackResponse } from "../generated/app-server/v2/ThreadRollbackResponse"; import type { ThreadSetNameResponse } from "../generated/app-server/v2/ThreadSetNameResponse"; import type { SortDirection } from "../generated/app-server/v2/SortDirection"; import type { ThreadStartResponse } from "../generated/app-server/v2/ThreadStartResponse"; @@ -52,6 +53,7 @@ interface ClientResponseByMethod { "thread/list": ThreadListResponse; "thread/archive": ThreadArchiveResponse; "thread/unarchive": ThreadUnarchiveResponse; + "thread/rollback": ThreadRollbackResponse; "thread/name/set": ThreadSetNameResponse; "thread/turns/list": ThreadTurnsListResponse; "skills/list": SkillsListResponse; @@ -222,6 +224,10 @@ export class AppServerClient { return this.request("thread/unarchive", { threadId }); } + rollbackThread(threadId: string): Promise { + return this.request("thread/rollback", { threadId, numTurns: 1 }); + } + setThreadName(threadId: string, name: string): Promise { return this.request("thread/name/set", { threadId, name }); } diff --git a/src/display/signature.ts b/src/display/signature.ts index f3ce5dfd..1a339b53 100644 --- a/src/display/signature.ts +++ b/src/display/signature.ts @@ -6,6 +6,7 @@ export interface DisplayItemSignatureContext { activeTurnId: string | null; displayItems: DisplayItem[]; workspaceRoot?: string | null; + canRollbackItem?: (item: DisplayItem) => boolean; } export function displayItemSignature(item: DisplayItem, context: DisplayItemSignatureContext): string { @@ -20,6 +21,7 @@ export function displayItemSignature(item: DisplayItem, context: DisplayItemSign "output" in item ? (item.output ?? "") : "", "details" in item ? JSON.stringify(item.details ?? []) : "", item.kind === "message" ? (item.editedFiles?.join("\n") ?? "") : "", + item.kind === "message" ? String(context.canRollbackItem?.(item) ?? false) : "", item.kind === "reasoning" && isReasoningActive(item, context) ? "reasoning-active" : "", executionState(item) ?? "", item.kind === "fileChange" ? JSON.stringify(item.changes) : "", diff --git a/src/main.ts b/src/main.ts index 7dd32db8..fdda1e68 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import { } from "./panel/collaboration-mode"; import { PanelController } from "./panel/controller"; import { connectionDiagnosticLines, connectionDiagnosticRows } from "./panel/diagnostics"; +import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./panel/rollback"; import { contextSummary, effectiveConfigSections, rateLimitSummary, type RateLimitSummary } from "./panel/runtime-view"; import { configRecord, @@ -577,9 +578,11 @@ class CodexPanelView extends ItemView { startNewThread: () => this.startNewThread(), resumeThread: (threadId) => this.resumeThread(threadId), forkThread: (threadId) => this.forkThread(threadId), + rollbackThread: (threadId) => this.rollbackThread(threadId), compactThread: async (threadId) => { await this.client?.compactThread(threadId); }, + busy: this.state.busy, toggleFastMode: () => this.toggleFastMode(), toggleCollaborationMode: () => this.toggleCollaborationMode(), addSystemMessage: (text) => this.addSystemMessage(text), @@ -1069,6 +1072,41 @@ class CodexPanelView extends ItemView { } } + private async rollbackThread(threadId: string): Promise { + if (this.state.busy) { + this.addSystemMessage("Interrupt the current turn before rolling back."); + return; + } + await this.ensureConnected(); + if (!this.client) return; + + const candidate = rollbackCandidateFromItems(this.state.displayItems); + if (!candidate) { + this.addSystemMessage("No completed turn to roll back."); + return; + } + + try { + this.setStatus("Rolling back latest turn..."); + const response = await this.client.rollbackThread(threadId); + this.state.activeThreadId = response.thread.id; + this.state.activeThreadCwd = response.thread.cwd ?? this.state.activeThreadCwd; + this.state.activeTurnId = null; + this.state.tokenUsage = null; + this.state.historyCursor = null; + this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread); + await this.history.loadLatest(response.thread.id); + this.setComposerText(candidate.text); + this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); + this.setStatus("Rolled back latest turn."); + this.refreshTabHeader(); + await this.refreshThreads(); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.setStatus("Rollback failed."); + } + } + private forceMessagesToBottom(): void { this.state.messagesPinnedToBottom = true; this.forceScrollMessagesToBottomOnNextRender = true; @@ -1084,6 +1122,7 @@ class CodexPanelView extends ItemView { const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl); this.forceScrollMessagesToBottomOnNextRender = false; this.state.messagesPinnedToBottom = shouldScrollToBottom; + const rollbackCandidate = this.state.busy ? null : rollbackCandidateFromItems(this.state.displayItems); const blocks = messageRenderBlocks({ activeThreadId: this.state.activeThreadId, @@ -1102,6 +1141,10 @@ class CodexPanelView extends ItemView { loadOlderTurns: () => void this.history.loadOlder(), renderMarkdown: (element, text) => this.renderMarkdownMessage(element, text), renderTextWithWikiLinks: (element, text) => this.renderTextWithWikiLinks(element, text), + canRollbackItem: (item) => isRollbackCandidateItem(item, rollbackCandidate), + onRollbackItem: () => { + if (this.state.activeThreadId) void this.rollbackThread(this.state.activeThreadId); + }, pendingRequestsSignature: this.pendingRequestsSignature(), renderPendingRequests: () => this.createPendingRequestsElement(), }); diff --git a/src/panel/rollback.ts b/src/panel/rollback.ts new file mode 100644 index 00000000..63a8626c --- /dev/null +++ b/src/panel/rollback.ts @@ -0,0 +1,38 @@ +import type { DisplayItem, MessageDisplayItem } from "../display/types"; + +export interface RollbackCandidate { + turnId: string; + itemId: string; + text: string; +} + +export function rollbackCandidateFromItems(items: DisplayItem[]): RollbackCandidate | null { + const lastTurnId = latestTurnId(items); + if (!lastTurnId) return null; + + const userMessage = items.find((item): item is MessageDisplayItem => isUserMessageForTurn(item, lastTurnId)); + if (!userMessage) return null; + + return { + turnId: lastTurnId, + itemId: userMessage.id, + text: userMessage.text, + }; +} + +export function isRollbackCandidateItem(item: DisplayItem, candidate: RollbackCandidate | null): boolean { + return Boolean( + candidate && item.kind === "message" && item.role === "user" && item.id === candidate.itemId && item.turnId === candidate.turnId, + ); +} + +function latestTurnId(items: DisplayItem[]): string | null { + for (const item of [...items].reverse()) { + if (item.turnId) return item.turnId; + } + return null; +} + +function isUserMessageForTurn(item: DisplayItem, turnId: string): item is MessageDisplayItem { + return item.kind === "message" && item.role === "user" && item.turnId === turnId; +} diff --git a/src/panel/slash-commands.ts b/src/panel/slash-commands.ts index da8f9dbd..f4bc0012 100644 --- a/src/panel/slash-commands.ts +++ b/src/panel/slash-commands.ts @@ -7,6 +7,7 @@ export const SLASH_COMMANDS = [ { command: "/new", detail: "Start a new Codex thread, optionally sending a message." }, { command: "/resume", detail: "Resume a recent Codex thread." }, { command: "/fork", detail: "Fork the active Codex thread." }, + { command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." }, { command: "/compact", detail: "Compact the current conversation context." }, { command: "/fast", detail: "Toggle fast service tier for subsequent turns." }, { command: "/plan", detail: "Toggle Plan mode, optionally sending a message." }, @@ -27,10 +28,12 @@ export function slashCommandHelpLines(): string[] { export interface SlashCommandExecutionContext { activeThreadId: string | null; + busy: boolean; listedThreads: Thread[]; startNewThread: () => Promise; resumeThread: (threadId: string) => Promise; forkThread: (threadId: string) => Promise; + rollbackThread: (threadId: string) => Promise; compactThread: (threadId: string) => Promise; toggleFastMode: () => void; toggleCollaborationMode: () => void; @@ -82,6 +85,23 @@ export async function executeSlashCommand( return; } + if (command === "rollback") { + if (args) { + context.addSystemMessage(`Unsupported slash command arguments: ${args}`); + return; + } + if (!context.activeThreadId) { + context.addSystemMessage("No active thread to roll back."); + return; + } + if (context.busy) { + context.addSystemMessage("Interrupt the current turn before rolling back."); + return; + } + await context.rollbackThread(context.activeThreadId); + return; + } + if (command === "compact") { if (!context.activeThreadId) { context.addSystemMessage("No active thread to compact."); diff --git a/src/view/message-stream.ts b/src/view/message-stream.ts index 96abf4dc..aeb6b836 100644 --- a/src/view/message-stream.ts +++ b/src/view/message-stream.ts @@ -1,7 +1,7 @@ import { displayBlocksForItems, executionState } from "../display/model"; import { displayItemSignature } from "../display/signature"; import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types"; -import { createMetaPair, createRememberedDetails } from "./components"; +import { createIconButton, createMetaPair, createRememberedDetails } from "./components"; import { applyExecutionStateClass } from "./execution-state"; import { renderToolResult } from "./tool-result"; import { @@ -31,6 +31,8 @@ export interface MessageStreamContext { loadOlderTurns: () => void; renderMarkdown: (parent: HTMLElement, text: string) => void; renderTextWithWikiLinks: (parent: HTMLElement, text: string) => void; + canRollbackItem?: (item: DisplayItem) => boolean; + onRollbackItem?: (item: DisplayItem) => void; pendingRequestsSignature?: string; renderPendingRequests?: () => HTMLElement | null; } @@ -163,7 +165,16 @@ function renderDisplayItem(parent: HTMLElement, item: DisplayItem, context: Mess const messageEl = parent.createDiv({ cls: messageClass(item) }); applyExecutionStateClass(messageEl, executionState(item)); - messageEl.createDiv({ cls: "codex-panel__message-role", text: displayRoleLabel(item) }); + const role = messageEl.createDiv({ cls: "codex-panel__message-role" }); + role.createSpan({ text: displayRoleLabel(item) }); + if (context.canRollbackItem?.(item)) { + const button = createIconButton(role, "undo-2", "Rollback last turn", "codex-panel__message-action codex-panel__rollback-turn"); + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + context.onRollbackItem?.(item); + }; + } const content = messageEl.createDiv({ cls: `codex-panel__message-content ${item.markdown === false ? "" : "markdown-rendered"}` }); if (item.markdown === false) { context.renderTextWithWikiLinks(content, item.text); diff --git a/styles.css b/styles.css index 2745e7f7..52934a47 100644 --- a/styles.css +++ b/styles.css @@ -835,6 +835,18 @@ font-weight: 600; } +.codex-panel__message-action { + --icon-size: var(--icon-xs, 14px); + --icon-stroke: var(--icon-xs-stroke-width, 2px); + opacity: 0; + transition: opacity 120ms ease-in-out; +} + +.codex-panel__message:hover .codex-panel__message-action, +.codex-panel__message-action:focus-visible { + opacity: 1; +} + .codex-panel__message--user { border-left-color: var(--interactive-accent); } diff --git a/tests/app-server-client.test.ts b/tests/app-server-client.test.ts index 639fcc79..b9aa82d0 100644 --- a/tests/app-server-client.test.ts +++ b/tests/app-server-client.test.ts @@ -368,6 +368,15 @@ describe("AppServerClient", () => { }); transport.emitLine({ id: 7, result: { data: [], nextCursor: null } }); await firstTurn; + + const rollback = client.rollbackThread("thread-1"); + expect(transport.sent[8]).toMatchObject({ + id: 8, + method: "thread/rollback", + params: { threadId: "thread-1", numTurns: 1 }, + }); + transport.emitLine({ id: 8, result: { thread: { id: "thread-1", turns: [] } } }); + await rollback; }); it("sends thread naming request payloads", async () => { diff --git a/tests/rollback.test.ts b/tests/rollback.test.ts new file mode 100644 index 00000000..608b166d --- /dev/null +++ b/tests/rollback.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { isRollbackCandidateItem, rollbackCandidateFromItems } from "../src/panel/rollback"; +import type { DisplayItem } from "../src/display/types"; + +describe("rollback candidate", () => { + it("selects the first user message from the latest turn", () => { + const items: DisplayItem[] = [ + { id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true }, + { id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true }, + { id: "a2", kind: "message", role: "assistant", text: "latest answer", turnId: "turn-2", markdown: true }, + ]; + + const candidate = rollbackCandidateFromItems(items); + + expect(candidate).toEqual({ turnId: "turn-2", itemId: "u2", text: "latest" }); + expect(isRollbackCandidateItem(items[2], candidate)).toBe(true); + expect(isRollbackCandidateItem(items[0], candidate)).toBe(false); + expect(isRollbackCandidateItem({ ...items[2], turnId: "turn-other" }, candidate)).toBe(false); + }); + + it("returns null when there is no completed user turn in display items", () => { + expect(rollbackCandidateFromItems([])).toBeNull(); + expect(rollbackCandidateFromItems([{ id: "system", kind: "system", role: "system", text: "Idle" }])).toBeNull(); + expect( + rollbackCandidateFromItems([{ id: "a1", kind: "message", role: "assistant", text: "answer", turnId: "turn-1", markdown: true }]), + ).toBeNull(); + }); +}); diff --git a/tests/slash-commands.test.ts b/tests/slash-commands.test.ts index 42e4a0f8..6458a446 100644 --- a/tests/slash-commands.test.ts +++ b/tests/slash-commands.test.ts @@ -6,10 +6,12 @@ import { executeSlashCommand, slashCommandHelpLines, type SlashCommandExecutionC function context(overrides: Partial = {}): SlashCommandExecutionContext { return { activeThreadId: "thread-1", + busy: false, listedThreads: [thread({ id: "thread-1", name: "Current" })], startNewThread: vi.fn().mockResolvedValue(undefined), resumeThread: vi.fn().mockResolvedValue(undefined), forkThread: vi.fn().mockResolvedValue(undefined), + rollbackThread: vi.fn().mockResolvedValue(undefined), compactThread: vi.fn().mockResolvedValue(undefined), toggleFastMode: vi.fn(), toggleCollaborationMode: vi.fn(), @@ -116,6 +118,41 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("Unsupported slash command arguments: anything"); }); + it("rolls back the active thread for /rollback", async () => { + const ctx = context({ activeThreadId: "active-thread" }); + + await executeSlashCommand("rollback", "", ctx); + + expect(ctx.rollbackThread).toHaveBeenCalledWith("active-thread"); + }); + + it("rejects /rollback without an active thread", async () => { + const ctx = context({ activeThreadId: null }); + + await executeSlashCommand("rollback", "", ctx); + + expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread to roll back."); + }); + + it("rejects /rollback while a turn is running", async () => { + const ctx = context({ busy: true }); + + await executeSlashCommand("rollback", "", ctx); + + expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Interrupt the current turn before rolling back."); + }); + + it("rejects /rollback arguments", async () => { + const ctx = context(); + + await executeSlashCommand("rollback", "2", ctx); + + expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Unsupported slash command arguments: 2"); + }); + it("toggles Plan mode without sending text for bare /plan", async () => { const ctx = context(); @@ -149,6 +186,12 @@ describe("slash commands", () => { ); }); + it("documents rollback", () => { + expect(slashCommandHelpLines().find((line) => line.startsWith("/rollback"))).toBe( + "/rollback - Drop the latest turn and restore its prompt to the composer.", + ); + }); + it("documents status and doctor as separate commands", () => { expect(slashCommandHelpLines().find((line) => line.startsWith("/status"))).toBe( "/status - Show current session, context, and usage limits.", diff --git a/tests/view-renderers.test.ts b/tests/view-renderers.test.ts index 338b2f02..14e24cf4 100644 --- a/tests/view-renderers.test.ts +++ b/tests/view-renderers.test.ts @@ -196,6 +196,57 @@ describe("view renderers", () => { expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]); }); + it("renders rollback action only for the eligible user message", () => { + const onRollbackItem = vi.fn(); + const items = [ + { id: "u1", kind: "message", role: "user", text: "older", turnId: "turn-1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "older answer", turnId: "turn-1", markdown: true }, + { id: "u2", kind: "message", role: "user", text: "latest", turnId: "turn-2", markdown: true }, + ] as const; + const blocks = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + displayItems: [...items], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + canRollbackItem: (item) => item.id === "u2", + onRollbackItem, + }); + + const rendered = blocks.map((block) => block.render()); + + expect(rendered[0].querySelector(".codex-panel__rollback-turn")).toBeNull(); + expect(rendered[1].querySelector(".codex-panel__rollback-turn")).toBeNull(); + const button = rendered[2].querySelector(".codex-panel__rollback-turn"); + expect(button?.getAttribute("aria-label")).toBe("Rollback last turn"); + button?.click(); + expect(onRollbackItem).toHaveBeenCalledWith(expect.objectContaining({ id: "u2" })); + }); + + it("does not render rollback action when no item is eligible", () => { + const block = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: true, + displayItems: [{ id: "u1", kind: "message", role: "user", text: "running", turnId: "turn-1", markdown: true }], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + canRollbackItem: () => false, + onRollbackItem: vi.fn(), + })[0]; + + expect(block.render().querySelector(".codex-panel__rollback-turn")).toBeNull(); + }); + it("renders command items as a compact summary with output behind details", () => { const block = messageRenderBlocks({ activeThreadId: "thread",