mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add latest turn rollback
This commit is contained in:
parent
c79dd88a67
commit
ddb391fe2b
11 changed files with 267 additions and 2 deletions
|
|
@ -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<ThreadRollbackResponse> {
|
||||
return this.request("thread/rollback", { threadId, numTurns: 1 });
|
||||
}
|
||||
|
||||
setThreadName(threadId: string, name: string): Promise<ThreadSetNameResponse> {
|
||||
return this.request("thread/name/set", { threadId, name });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) : "",
|
||||
|
|
|
|||
43
src/main.ts
43
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<void> {
|
||||
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(),
|
||||
});
|
||||
|
|
|
|||
38
src/panel/rollback.ts
Normal file
38
src/panel/rollback.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
12
styles.css
12
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
30
tests/rollback.test.ts
Normal file
30
tests/rollback.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -6,10 +6,12 @@ import { executeSlashCommand, slashCommandHelpLines, type SlashCommandExecutionC
|
|||
function context(overrides: Partial<SlashCommandExecutionContext> = {}): 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.",
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(".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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue