Merge commit 'refs/pr12814' into takeover/12814

This commit is contained in:
yhh committed 2026-10-12 08:03:47 -07:00
commit c430ae5fe3
36 files changed
+907 -131

No files matched your search

+8
View File
@@ -2099,3 +2099,11 @@ test("a parked page is attached again when it is shown, and a page already on sc
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
savePrefs(file, record);
assert.deepEqual(loadPrefs(file), record);
});
+5
View File
@@ -768,6 +768,11 @@ export const EN: Record<string, string> = {
"正在还原…": "Restoring…",
"会话已切换,请重新选择还原位置": "The conversation changed. Choose a restore point again.",
"撤销这次还原": "Undo this restore",
"回退已完成": "Rewind completed",
"关闭回退提示": "Dismiss rewind notice",
"无法保存关闭状态:{error}": "Could not save dismissal: {error}",
"撤销这次回退": "Undo this rewind",
"正在撤销…": "Undoing…",
"仍还原其余部分": "Restore the rest anyway",
"还原失败": "Could not restore",
"回退范围内未修改任何文件": "No files changed in this rewind range",
+22 -6
View File
@@ -1,6 +1,6 @@
import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -17,7 +17,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
private log: WireEvent[] = [];
// What the user has sent, so checkpoints() can mirror one per turn.
private prompts: string[] = [];
private undone: string[] | null = null;
private undone: { prompts: string[]; offer: RewindUndo } | null = null;
private at = 0;
// The kernel numbers every frame a client cannot afford to miss, and a
// bootstrap cut is read against those numbers.
@@ -336,6 +336,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async newSession() {
this.clearUndo();
this.log = [];
this.at = 0;
this.state.goal = "";
@@ -430,13 +431,26 @@ export class MockPort extends MockWorkspace implements AgentPort {
async commitRewind(planId: string): Promise<RewindResult> {
const turn = Number(planId.split("-")[2] ?? 0), files = !planId.endsWith("conversation") && this.prompts.length > 1;
this.undone = this.prompts.slice();
this.prompts = this.prompts.slice(0, turn);
return { ok: true, transactionId: `mock-tx-${turn}`, undoAvailable: true, written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
const scope = planId.split("-")[3];
const offer = { transactionId: `mock-tx-${turn}-${crypto.randomUUID()}`, turn, files: files ? 3 : 0 };
this.undone = { prompts: this.prompts.slice(), offer };
this.emit({ kind: "rewind_changed" });
if (scope !== "code") this.prompts = this.prompts.slice(0, turn);
return { ok: true, transactionId: offer.transactionId, undoAvailable: true, conversationOk: scope !== "code",
written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
}
private clearUndo() {
if (!this.undone) return;
this.undone = null;
this.emit({ kind: "rewind_changed" });
}
async availableUndo(): Promise<RewindUndo | null> { return this.state.running ? null : this.undone?.offer ?? null; }
async undoRewind(_transactionId: string): Promise<void> {
if (this.undone) this.prompts = this.undone;
if (this.undone) this.prompts = this.undone.prompts;
this.clearUndo();
}
subscribe(onEvent: (ev: WireEvent) => void, _onGap?: () => void, bootstrap?: () => Promise<number>) {
@@ -667,6 +681,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
this.emit({ kind: "steer", text });
return;
}
this.clearUndo();
this.prompts.push(text);
// The first turn is what puts the session on disk. serve answers with a
// truncated first message straight away and swaps in the generated title
@@ -692,6 +707,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
}
async resume(_path: string) {
this.clearUndo();
this.log = [];
this.at = 0;
}
+3 -2
View File
@@ -19,7 +19,7 @@ export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
@@ -33,7 +33,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
@@ -462,6 +462,7 @@ export interface AgentPort {
// Copies the conversation up to the end of a turn into a new session and
// answers with its path; this session stays where it is.
branchAt(turn: number, name: string): Promise<string>;
availableUndo(): Promise<RewindUndo | null>;
// Reverses a committed rewind. Only reachable while the caller still holds the
// transaction id the commit returned.
undoRewind(transactionId: string): Promise<void>;
@@ -102,6 +102,12 @@ export interface RewindFileStage {
compensateError?: string;
}
export interface RewindUndo {
transactionId: string;
turn: number;
files: number;
}
export interface RewindResult {
ok?: boolean;
transactionId?: string;
+2 -1
View File
@@ -1,5 +1,5 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -511,6 +511,7 @@ export class SsePort extends SseWorkspace implements AgentPort {
async branchAt(turn: number, name: string) {
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
}
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
undoRewind(transactionId: string) {
return this.post("/rewind/undo", { transactionId });
+1
View File
@@ -32,6 +32,7 @@ export type Kind =
| "workspace_changed"
| "completion_summary"
| "inbox_changed"
| "rewind_changed"
| "adjudications_changed"
| "browser_tabs_changed"
| "graph_delta"
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from "react";
const PREFIX = "rx-rewind-notice-dismissed:";
const listeners = new Set<() => void>();
function dismissed(transactionId: string | undefined): boolean {
if (!transactionId) return false;
try { return localStorage.getItem(PREFIX + transactionId) === "1"; }
catch { return false; }
}
function refresh(event: StorageEvent) {
if (event.key === null || event.key.startsWith(PREFIX)) listeners.forEach((fn) => fn());
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
if (listeners.size === 1) window.addEventListener("storage", refresh);
return () => {
listeners.delete(fn);
if (listeners.size === 0) window.removeEventListener("storage", refresh);
};
}
export function dismissRewindNotice(transactionId: string): void {
localStorage.setItem(PREFIX + transactionId, "1");
listeners.forEach((fn) => fn());
}
export function useRewindNoticeDismissed(transactionId: string | undefined): boolean {
return useSyncExternalStore(subscribe, () => dismissed(transactionId), () => false);
}
@@ -55,6 +55,7 @@ export const initialState: SessionState = {
steerQueue: [], takenBack: [], subagentPhase: {},
awaitingTurnStart: [],
queueMoved: 0,
rewindMoved: 0,
browserTabsMoved: 0,
panels: [],
views: [],
@@ -221,6 +222,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
}
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
if (ev.kind === "rewind_changed") return { ...s, rewindMoved: s.rewindMoved + 1 };
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
if (ev.kind === "browser_tabs_changed") return { ...s, browserTabsMoved: s.browserTabsMoved + 1 };
// Both event.Message emitters carry assistant text, so nothing on the wire
@@ -243,6 +243,7 @@ export interface SessionState {
// and this is what asks it again. Counting, not the kernel's own revision:
// the point is that it changed, and a counter cannot arrive out of order.
queueMoved: number;
rewindMoved: number;
// How many times the agent's browser tabs moved; the tabs are read back.
browserTabsMoved: number;
// Standing extension surfaces, keyed by plugin and surface id. They describe
@@ -0,0 +1,50 @@
.pbody.rewind-notice-host { position: relative; }
.rewind-notice {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 9px;
flex: none;
align-self: center;
width: fit-content;
max-width: min(420px, calc(100% - 24px));
margin: 0;
padding: 7px 36px 7px 10px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: var(--float);
color: var(--text);
box-shadow: var(--shadow-float);
font: var(--w-med) var(--fs-ui)/1.45 var(--ui);
}
.rewind-notice > .studio-icon { flex: none; color: var(--muted); }
.rewind-notice-copy { display: flex; flex: 0 1 auto; flex-direction: column; gap: 4px; min-width: 0; }
.rewind-notice-error { color: var(--err-ink); overflow-wrap: anywhere; }
.rewind-notice > .btn { flex: none; padding: 5px 9px; white-space: nowrap; }
@container conversation (max-width: 460px) {
.rewind-notice { max-width: calc(100% - 20px); flex-wrap: wrap; }
}
.rewind-notice-close {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.rewind-notice-close:hover:not(:disabled) { color: var(--text); }
.rewind-notice-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.rewind-notice-close:disabled { opacity: .45; cursor: default; }
.rewind-notice-count { color: var(--muted); }
+6 -4
View File
@@ -14,7 +14,8 @@ import { useReplyActions } from "./reply";
import { useGateActions } from "./gates";
import { useQueueActions } from "./queueactions";
import { useRewindActions } from "./rewind";
import { ReloadNotice, RestoreNotice } from "./RestoreNotice";
import { ReloadNotice } from "./RestoreNotice";
import { RewindNotice } from "./RewindNotice";
import { initialTraj, reduceTraj } from "../state/trajectory";
import { Transcript } from "./Transcript";
import { useBackgroundDeltas } from "./deltas";
@@ -344,7 +345,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
sessionState: sessionRead.kind,
sessionPath: status?.sessionPath,
});
const { reloadFailure, onReloadSession, restoreNotice, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText, JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]), running);
const { reloadFailure, onReloadSession, restoreNotice, onUndoAvailable, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText, JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]), running);
const onReadUndo = useCallback(() => port.availableUndo(), [port]);
const { onApprove, onFullAccess, onPlan, onForget, onExtInvoke, onExtSubmit, onAnswer } = useGateActions({
port,
@@ -412,7 +414,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
<Find find={find} />
<div className="pbody" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
<div className="pbody rewind-notice-host" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
<div className="pviews">
<ReceiptPrefContext.Provider value={receiptPref}>
@@ -546,7 +549,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)}
/>
{reloadFailure && <ReloadNotice failure={reloadFailure} onReload={onReloadSession} />}
{restoreNotice && <RestoreNotice receipt={restoreNotice} refreshing={reloadFailure?.working} onUndo={onUndoRewind} onDismiss={dismissRestore} />}
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
both are dismissed one at a time, so nothing else bounds how many can
be on screen at once. */}
@@ -45,7 +45,7 @@ it("resets native paint with a resting click cue, themed focus and disabled stat
expect(style(".restore-notice button:disabled").getPropertyValue("cursor")).toBe("default");
});
it.each(["RestoreNotice", "ReloadNotice"])("reserves composer space for %s outside the runtime-notes ceiling", (component) => {
it.each([{ component: "RewindNotice", parent: "pbody rewind-notice-host" }, { component: "ReloadNotice", parent: "compose" }])("places $component outside the runtime-notes ceiling", ({ component, parent: expectedParent }) => {
const parents: string[] = [];
walkStack(parse(pane, { sourceType: "module", plugins: ["typescript", "jsx"] }), (node, stack) => {
if (node.type !== "JSXElement" || ((node.openingElement as Node).name as Node).name !== component) return;
@@ -53,7 +53,7 @@ it.each(["RestoreNotice", "ReloadNotice"])("reserves composer space for %s outsi
const attrs = (parent?.openingElement as Node).attributes as Node[];
parents.push((attrs.find((a) => (a.name as Node)?.name === "className")?.value as Node)?.value as string);
});
expect(parents).toEqual(["compose"]);
expect(parents).toEqual([expectedParent]);
});
it("bounds only the error, with zoom-aware scrolling and a keyboard target", () => {
@@ -0,0 +1,160 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { RewindNotice } from "./RewindNotice";
import { MockPort } from "../port/mock";
import type { RewindUndo } from "../port/port";
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); vi.useRealTimers(); });
const saved: RewindUndo = { transactionId: "tx-saved", turn: 2, files: 0 };
function draw() {
let available: RewindUndo | null = saved;
const readUndo = vi.fn(async () => available);
const onUndo = vi.fn(async (_id: string) => { available = null; });
const props = { readUndo, onUndo, refreshKey: 0, running: false, shown: true };
const view = render(<RewindNotice {...props} />);
return { props, view, readUndo, onUndo, invalidate: () => { available = null; } };
}
it("recovers a saved offer on a fresh mount without any message card", async () => {
const { props, view } = draw();
await screen.findByRole("button", { name: "撤销这次回退" });
view.unmount();
render(<RewindNotice {...props} />);
await screen.findByRole("button", { name: "撤销这次回退" });
expect(screen.getByRole("status").textContent).toContain("回退已完成");
});
it("hides the notice when undo succeeds", async () => {
const { onUndo } = draw();
await userEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
expect(onUndo).toHaveBeenCalledWith("tx-saved");
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
});
it("shows a failed undo without losing the backend offer", async () => {
const { onUndo } = draw();
onUndo.mockRejectedValueOnce(new Error("file changed"));
await userEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
expect((await screen.findByRole("alert")).textContent).toContain("file changed");
expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(false);
});
it("hides during a new turn and does not revive an invalidated offer afterwards", async () => {
const { props, view, invalidate, readUndo } = draw();
await screen.findByRole("button", { name: "撤销这次回退" });
view.rerender(<RewindNotice {...props} running />);
expect(screen.queryByRole("status")).toBeNull();
invalidate();
view.rerender(<RewindNotice {...props} running={false} />);
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("status")).toBeNull();
});
it("does not poll while a visible pane is idle", async () => {
vi.useFakeTimers();
const { readUndo } = draw();
await act(async () => {});
await act(async () => { vi.advanceTimersByTime(30000); });
expect(readUndo).toHaveBeenCalledTimes(1);
});
it("reads once when an external undo-change event arrives", async () => {
const { props, view, invalidate, readUndo } = draw();
await screen.findByRole("button", { name: "撤销这次回退" });
invalidate();
view.rerender(<RewindNotice {...props} moved={1} />);
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
expect(readUndo).toHaveBeenCalledTimes(2);
});
it("ignores an old session's query after switching to another session", async () => {
let finishOld!: (value: RewindUndo | null) => void;
const old = new Promise<RewindUndo | null>((resolve) => { finishOld = resolve; });
const next = { ...saved, transactionId: "tx-next" };
const readUndo = vi.fn().mockReturnValueOnce(old).mockResolvedValue(next);
const onUndo = vi.fn(async (_id: string) => { readUndo.mockResolvedValue(null); });
const props = { readUndo, onUndo, refreshKey: 0, running: false, shown: true };
const view = render(<RewindNotice key="old-session" {...props} />);
view.rerender(<RewindNotice key="next-session" {...props} />);
await screen.findByRole("button", { name: "撤销这次回退" });
await act(async () => { finishOld(saved); });
await userEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
expect(onUndo).toHaveBeenCalledWith("tx-next");
});
it("keeps a dismissed offer hidden after an event refresh and remount", async () => {
const { props, view, readUndo, onUndo } = draw();
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
expect(screen.queryByRole("status")).toBeNull();
expect(onUndo).not.toHaveBeenCalled();
view.rerender(<RewindNotice {...props} moved={1} />);
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("status")).toBeNull();
view.unmount();
render(<RewindNotice {...props} />);
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("status")).toBeNull();
});
it("shows a new transaction without reviving dismissed transactions", async () => {
const { props, view, readUndo } = draw();
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
const next = { ...saved, transactionId: "tx-new" };
readUndo.mockResolvedValue(next);
view.rerender(<RewindNotice {...props} moved={1} />);
expect(await screen.findByRole("button", { name: "撤销这次回退" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "关闭回退提示" }));
readUndo.mockResolvedValue(saved);
view.rerender(<RewindNotice {...props} moved={2} />);
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("status")).toBeNull();
});
it("dismisses the same transaction in other mounted panes", async () => {
const props = { readUndo: vi.fn(async () => saved), onUndo: vi.fn(), refreshKey: 0, running: false, shown: true };
render(<><RewindNotice {...props} /><RewindNotice {...props} /></>);
await waitFor(() => expect(screen.getAllByRole("button", { name: "关闭回退提示" })).toHaveLength(2));
await userEvent.click(screen.getAllByRole("button", { name: "关闭回退提示" })[0]!);
expect(screen.queryAllByRole("status")).toHaveLength(0);
expect(props.onUndo).not.toHaveBeenCalled();
});
it("reports a persistence failure rather than claiming the notice was dismissed", async () => {
const { onUndo } = draw();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage blocked"); });
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
expect((await screen.findByRole("alert")).textContent).toContain("storage blocked");
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
expect(onUndo).not.toHaveBeenCalled();
});
it("gives repeated mock rewinds their own transaction identity", async () => {
const port = new MockPort();
const first = await port.commitRewind("mock-plan-0-code");
const next = await port.commitRewind("mock-plan-0-code");
expect(next.transactionId).not.toBe(first.transactionId);
});
it("observes dismissal persisted by another browser window", async () => {
draw();
await screen.findByRole("button", { name: "关闭回退提示" });
await act(async () => {
const key = "rx-rewind-notice-dismissed:" + saved.transactionId;
localStorage.setItem(key, "1");
window.dispatchEvent(new StorageEvent("storage", { key }));
});
expect(screen.queryByRole("status")).toBeNull();
});
it("disables actions while the session owner is completing a restore or refresh", async () => {
const { props, view, onUndo } = draw();
await screen.findByRole("button", { name: "撤销这次回退" });
view.rerender(<RewindNotice {...props} busy />);
expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByRole("button", { name: "关闭回退提示" }).hasAttribute("disabled")).toBe(true);
expect(onUndo).not.toHaveBeenCalled();
});
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { RewindUndo } from "../port/port";
import { StudioIcon } from "./StudioIcon";
import { dismissRewindNotice, useRewindNoticeDismissed } from "../state/rewind_notice";
import "../styles/rewind-notice.css";
export function RewindNotice({ readUndo, onUndo, refreshKey, moved = 0, busy = false, running, shown }: {
readUndo: () => Promise<RewindUndo | null>;
onUndo: (transactionId: string) => Promise<void>;
refreshKey: unknown;
moved?: number;
busy?: boolean;
running: boolean;
shown: boolean;
}) {
const [offer, setOffer] = useState<RewindUndo | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const reads = useRef(0);
const dismissed = useRewindNoticeDismissed(offer?.transactionId);
useEffect(() => {
if (!shown || running) {
setOffer(null);
setError("");
return;
}
if (pending) return;
let alive = true;
const seq = ++reads.current;
// The event signals a change; only the backend owns the current offer.
readUndo().then((next) => {
if (!alive || seq !== reads.current) return;
setOffer((prev) => prev?.transactionId === next?.transactionId && prev?.turn === next?.turn && prev?.files === next?.files ? prev : next);
if (!next) setError("");
}).catch(() => {
if (alive && seq === reads.current) setOffer(null);
});
return () => { alive = false; };
}, [readUndo, refreshKey, moved, running, shown, pending]);
const undo = async () => {
if (!offer || pending || busy) return;
setPending(true);
setError("");
try {
await onUndo(offer.transactionId);
setOffer(null);
} catch (e) {
setError(reason(e));
} finally {
setPending(false);
}
};
const dismiss = () => {
if (!offer || pending || busy) return;
try { dismissRewindNotice(offer.transactionId); }
catch (e) { setError(t("无法保存关闭状态:{error}", { error: reason(e) })); }
};
if (!shown || running || !offer || dismissed) return null;
return (
<div className="rewind-notice" role="status" aria-live="polite" aria-busy={pending || busy}>
<button type="button" className="rewind-notice-close" data-action="rewind.notice-dismiss" data-target={offer.transactionId} aria-label={t("关闭回退提示")} title={t("关闭回退提示")} disabled={pending || busy} onClick={dismiss}>
<StudioIcon name="close" />
</button>
<StudioIcon name="rewind" />
<div className="rewind-notice-copy">
<span>{t("回退已完成")}</span>
<span className="rewind-notice-count">{t("已还原 {n} 个文件", { n: offer.files })}</span>
{error && <span className="rewind-notice-error" role="alert">{error}</span>}
</div>
<button type="button" className="btn" data-action="rewind.undo" data-target={offer.transactionId} disabled={pending || busy} onClick={() => void undo()}>
{t(pending ? "正在撤销…" : "撤销这次回退")}
</button>
</div>
);
}
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import { HttpError, type HistoryMessage, type RewindScope, type RewindUndo } from "../port/port";
import type { WireEvent } from "../port/wire";
import type { RuntimeView } from "../port/hub";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
const PROMPT = "Create a sample file";
const HISTORY: HistoryMessage[] = [
{ role: "user", content: PROMPT, msgIndex: 0 },
{ role: "assistant", content: "Created the file.", msgIndex: 1 },
];
const props = {
rt: { id: "p1", root: "/sample", name: "Sample" } as RuntimeView,
title: "Sample", active: true, visible: true, sideHost: null, side: false,
onFocus() {}, onReport() {}, onSessionChanged() {}, pulse: 0, findPulse: 0,
onSettings() {}, needsProject: false, onOpenProject() {}, onKeepHere() {},
theme: "dark", dockW: 560, dockMax: 880, onDockW() {},
};
function open(recovered = false) {
const port = new MockPort();
const listeners = new Set<(event: WireEvent) => void>();
vi.spyOn(port, "subscribe").mockImplementation((listener) => {
listeners.add(listener); return () => listeners.delete(listener);
});
let history = recovered ? [] : HISTORY;
let offer: RewindUndo | null = recovered ? { transactionId: "tx-saved", turn: 1, files: 0 } : null;
vi.spyOn(port, "history").mockImplementation(async () => history);
vi.spyOn(port, "checkpoints").mockImplementation(async () => history.length ? [{
turn: 1, prompt: PROMPT, files: 1, msgIndex: 0,
}] : []);
vi.spyOn(port, "prepareRewind").mockImplementation(async (turn, scope) => ({
planId: scope, turn, coverage: "full", canFiles: true, canConversation: true,
fileCount: 1, requiresConfirmation: false,
}));
vi.spyOn(port, "commitRewind").mockImplementation(async (scope) => {
if (scope !== "code") history = [];
offer = { transactionId: "tx-new", turn: 1, files: scope === "conversation" ? 0 : 1 };
return { ok: true, conversationOk: scope !== "code", transactionId: offer.transactionId, undoAvailable: true };
});
vi.spyOn(port, "availableUndo").mockImplementation(async () => offer);
vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = HISTORY; offer = null; });
return { port, mount: () => render(<Pane {...props} port={port} />), changeOffer(next: RewindUndo | null) {
offer = next; listeners.forEach((listener) => listener({ kind: "rewind_changed" }));
} };
}
it.each([
["code", "只还原代码"], ["conversation", "只回退对话"], ["both", "代码和对话"],
] as [RewindScope, string][])("offers undo after a %s rewind with realistic history", async (scope, label) => {
const { port, mount } = open();
mount();
fireEvent.click((await screen.findAllByRole("button", { name: "回到这里" })).at(-1)!);
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(label) }));
const undo = await screen.findByRole("button", { name: "撤销这次回退" }, { timeout: 5000 });
expect(port.prepareRewind).toHaveBeenCalledWith(1, scope);
if (scope === "code") expect(screen.getByText(PROMPT)).toBeTruthy();
else await waitFor(() => expect(screen.queryByRole("button", { name: "回到这里" })).toBeNull());
fireEvent.click(undo);
await waitFor(() => expect(port.undoRewind).toHaveBeenCalledExactlyOnceWith("tx-new"));
await screen.findByText(PROMPT, { selector: ".txt" });
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
});
it("recovers a conversation undo on a fresh pane without the removed user row", async () => {
const { port, mount } = open(true);
mount();
fireEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
await waitFor(() => expect(port.undoRewind).toHaveBeenCalledExactlyOnceWith("tx-saved"));
await screen.findByText(PROMPT, { selector: ".txt" });
});
it("leaves the normal rewind menu usable when the host lacks the availability route", async () => {
const { port, mount } = open();
vi.mocked(port.availableUndo).mockRejectedValue(new HttpError(404, "not found"));
mount();
fireEvent.click((await screen.findAllByRole("button", { name: "回到这里" })).at(-1)!);
expect(screen.getByRole("menuitem", { name: /只还原代码/ })).toBeTruthy();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
});
it("refreshes an idle pane from a content-free event without a local turn", async () => {
const { mount, changeOffer } = open();
mount();
await screen.findAllByRole("button", { name: "回到这里" });
changeOffer({ transactionId: "remote-tx", turn: 1, files: 0 });
await screen.findByRole("button", { name: "撤销这次回退" });
changeOffer(null);
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
});
it("keeps a recovered offer dismissed when the entire pane is reopened", async () => {
const { port, mount } = open(true);
const view = mount();
fireEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
expect(port.undoRewind).not.toHaveBeenCalled();
view.unmount();
mount();
await act(async () => {});
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
});
it("shows a recovered undo failure and allows retry through the session owner", async () => {
const { port, mount } = open(true);
vi.mocked(port.undoRewind).mockRejectedValueOnce(new Error("files changed"));
mount();
fireEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
expect((await screen.findByRole("alert")).textContent).toContain("files changed");
await waitFor(() => expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(false));
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
await screen.findByText(PROMPT, { selector: ".txt" });
expect(port.undoRewind).toHaveBeenCalledTimes(2);
});
@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { afterEach, expect, it, vi } from "vitest";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { HistoryMessage, RewindResult } from "../port/port";
import type { HistoryMessage, RewindResult, RewindUndo } from "../port/port";
import type { WireEvent } from "../port/wire";
import type { RuntimeView } from "../port/hub";
@@ -29,6 +29,9 @@ async function open(conversation = true, holdInitialStatus = false, runtimePath:
if (display) await port.saveFoldWorkSettings(display.foldWork);
let session = "session-a";
let running = false;
let offer: RewindUndo | null = null;
let transaction = 0;
vi.spyOn(port, "availableUndo").mockImplementation(async () => running ? null : offer);
let emit: (event: WireEvent) => void = () => {};
const subscribe = port.subscribe.bind(port);
vi.spyOn(port, "subscribe").mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
@@ -41,12 +44,14 @@ async function open(conversation = true, holdInitialStatus = false, runtimePath:
vi.spyOn(port, "prepareRewind").mockResolvedValue({ planId: "plan-1", turn: 1, coverage: "full", canFiles: true, canConversation: true, fileCount: mutation ? 3 : 1, requiresConfirmation: false });
vi.spyOn(port, "commitRewind").mockImplementation(async () => {
if (conversation) history = [];
return { ok: true, conversationOk: conversation, transactionId: "tx-1", undoAvailable: true, deleted: ["file.txt"], ...mutation };
const result = { ok: true, conversationOk: conversation, transactionId: `tx-${++transaction}`, undoAvailable: true, deleted: ["file.txt"], ...mutation };
offer = { transactionId: result.transactionId, turn: 1, files: (result.written?.length ?? 0) + (result.deleted?.length ?? 0) };
return result;
});
const undo = vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = messages; });
const undo = vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = messages; offer = null; });
const view = render(<Pane {...props} rt={{ ...props.rt, sessionPath: runtimePath }} port={port} />);
await screen.findByRole("button", { name: "回到这里" });
return { port, reads, undo, view, resolveStatus, start: () => { running = true; emit({ kind: "turn_started" } as WireEvent); }, setSession: (next: string) => { session = next; } };
return { port, reads, undo, view, resolveStatus, start: () => { running = true; emit({ kind: "turn_started" } as WireEvent); }, setSession: (next: string) => { session = next; offer = null; } };
}
async function restore(scope = "代码和对话") {
fireEvent.click(screen.getByRole("button", { name: "回到这里" }));
@@ -59,7 +64,7 @@ it("preserves canonical attachments through conversation restore and undo", asyn
const src = port.workspaceImageURL(".reasonix/attachments/restore-picture.png");
expect(image().getAttribute("src")).toBe(src);
await restore();
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
await waitFor(() => expect(button.disabled).toBe(false));
expect(screen.queryByRole("img", { name: "restore-picture.png" })).toBeNull();
fireEvent.click(button);
@@ -88,13 +93,13 @@ it.each([false, true])("preserves speaker grouping and work folding through rest
};
await waitFor(assertTranscript);
await restore();
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
await waitFor(() => expect(button.disabled).toBe(false));
expect(document.querySelectorAll('[data-k="say"]')).toHaveLength(0);
fireEvent.click(button);
await waitFor(() => expect(undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
await waitFor(assertTranscript);
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
});
it.each([true, false])("keeps undo through the real Pane reload with conversation removal %j", async (conversation) => {
@@ -104,41 +109,41 @@ it.each([true, false])("keeps undo through the real Pane reload with conversatio
await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(before));
await waitFor(() => expect(!!screen.queryByRole("button", { name: "回到这里" })).toBe(!conversation));
expect(screen.getByText("已还原 1 个文件")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
await waitFor(() => expect(undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
await screen.findByRole("button", { name: "回到这里" });
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
});
it("keeps undo after unrelated rerender, supports a refused undo retry and repeated restore", async () => {
const h = await open();
await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
h.view.rerender(<Pane {...props} port={h.port} title="Renamed conversation" />);
h.undo.mockRejectedValueOnce(new Error("Tracked file changed"));
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
await screen.findByRole("alert", { name: "" });
expect(screen.getByText("Tracked file changed")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
await screen.findByRole("button", { name: "回到这里" });
await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
expect(h.port.commitRewind).toHaveBeenCalledTimes(2);
});
it("allows explicit dismissal without invoking undo", async () => {
const h = await open(); await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
fireEvent.click(screen.getByRole("button", { name: "知道了" }));
await screen.findByRole("button", { name: "撤销这次回退" });
fireEvent.click(screen.getByRole("button", { name: "关闭回退提示" }));
expect(screen.queryByText("已还原 1 个文件")).toBeNull(); expect(h.undo).not.toHaveBeenCalled();
});
it("clears the receipt when host status changes even while runtime metadata is stale", async () => {
const h = await open(); await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
h.setSession("session-b");
h.view.rerender(<Pane {...props} port={h.port} pulse={1} />);
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull());
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
expect(h.undo).not.toHaveBeenCalled();
});
@@ -151,7 +156,7 @@ it("does not publish a pending old-session restore or append its prompt to the n
await screen.findByRole("button", { name: "回到这里" });
const before = h.reads.mock.calls.length;
await act(async () => resolve({ ok: true, conversationOk: true, transactionId: "tx-1", undoAvailable: true, deleted: ["file.txt"] }));
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
expect(h.reads.mock.calls).toHaveLength(before);
expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("");
});
@@ -159,40 +164,42 @@ it("does not publish a pending old-session restore or append its prompt to the n
it("does not mistake initial status hydration for leaving the known conversation", async () => {
const h = await open(true, true);
await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
await act(async () => h.resolveStatus!());
expect(screen.getByRole("button", { name: "撤销这次还原" })).toBeTruthy();
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
});
it("disables repeated undo clicks while the operation is in flight", async () => {
const h = await open(); await restore();
const button = await screen.findByRole("button", { name: "撤销这次还原" });
const button = await screen.findByRole("button", { name: "撤销这次回退" });
let resolve!: () => void;
h.undo.mockReturnValue(new Promise((done) => { resolve = done; }));
const applyUndo = h.undo.getMockImplementation()!;
const pending = new Promise<void>((done) => { resolve = done; });
h.undo.mockImplementation(async (tx) => { await pending; await applyUndo(tx); });
fireEvent.click(button); fireEvent.click(button);
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(h.undo).toHaveBeenCalledExactlyOnceWith("tx-1");
await act(async () => resolve());
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
});
it("keeps a receipt when runtime metadata catches up to the known host session", async () => {
const h = await open(true, false, "");
await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
h.view.rerender(<Pane {...props} port={h.port} />);
expect(screen.getByRole("button", { name: "撤销这次还原" })).toBeTruthy();
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
});
it("retries history after a successful undo without applying the transaction twice", async () => {
const h = await open(); await restore();
const undo = await screen.findByRole("button", { name: "撤销这次还原" });
const undo = await screen.findByRole("button", { name: "撤销这次回退" });
await waitFor(() => expect((undo as HTMLButtonElement).disabled).toBe(false));
h.reads.mockRejectedValueOnce(new Error("History unavailable"));
fireEvent.click(undo);
const retry = await screen.findByRole("button", { name: "重试刷新会话" });
expect(screen.getByRole("alert").textContent).toBe("History unavailable");
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
fireEvent.click(retry);
await screen.findByRole("button", { name: "回到这里" });
expect(screen.queryByRole("button", { name: "重试刷新会话" })).toBeNull();
@@ -202,7 +209,7 @@ it("retries history after a successful undo without applying the transaction twi
it.each(["commit", "undo"])("reloads the actual Pane after %s completes behind a new-run event", async (operation) => {
const h = await open();
if (operation === "undo") { await restore(); await screen.findByRole("button", { name: "撤销这次还原" }); }
if (operation === "undo") { await restore(); await screen.findByRole("button", { name: "撤销这次回退" }); }
let release!: () => void;
const response = new Promise<void>((resolve) => { release = resolve; });
const commit = vi.mocked(h.port.commitRewind);
@@ -210,14 +217,14 @@ it.each(["commit", "undo"])("reloads the actual Pane after %s completes behind a
if (operation === "commit") commit.mockImplementation(async (plan) => { const result = await mutation(plan); await response; return result as RewindResult; });
else h.undo.mockImplementation(async (tx) => { await mutation(tx); await response; });
if (operation === "commit") await restore();
else fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
else fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
await act(async () => h.start());
const before = h.reads.mock.calls.length;
await act(async () => release());
await waitFor(() => expect(h.reads.mock.calls.length).toBeGreaterThan(before));
const userCards = h.view.container.querySelectorAll('[data-k="me"] .txt');
expect([...userCards].map((card) => card.textContent)).toEqual(operation === "undo" ? ["Restore this turn"] : []);
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe(operation === "undo" ? "Restore this turn" : "");
});
@@ -226,10 +233,10 @@ it("does not replay a pending rewind history read into a switched conversation",
let resolve!: (history: HistoryMessage[]) => void;
h.reads.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
await restore();
await screen.findByRole("button", { name: "撤销这次还原" });
await screen.findByRole("button", { name: "撤销这次回退" });
h.setSession("session-b");
h.view.rerender(<Pane {...props} port={h.port} pulse={1} />);
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull());
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
await act(async () => resolve([{ role: "user", content: "Stale prior-session text", msgIndex: 0 }]));
expect(screen.queryByText("Stale prior-session text")).toBeNull();
});
@@ -247,7 +254,7 @@ it.each([
expect(screen.getAllByText("3 个文件")).toHaveLength(2);
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(conversation ? "只回退对话" : "只还原代码") }));
await screen.findByText(`已还原 ${count} 个文件`);
const undo = screen.getByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
const undo = screen.getByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
await waitFor(() => expect(undo.disabled).toBe(false));
fireEvent.click(undo);
await waitFor(() => expect(h.undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
+9 -1
View File
@@ -102,6 +102,14 @@ export function useRewindActions(port: AgentPort, reloadSession: (current?: () =
void operation.finally(() => { if (owner.undo === operation) owner.undo = null; });
return operation;
}, [port, owner, publish, onReloadSession]);
const onUndoAvailable = useCallback(async (transactionId: string) => {
if (!owner.active) throw new Error(t("会话已切换,请重新选择还原位置"));
if (owner.commit || owner.reload || (owner.undo && owner.notice?.tx !== transactionId)) throw new Error(t("正在还原…"));
// Backend offers survive reloads that retired the local restore receipt.
if (owner.notice?.tx !== transactionId) publish({ tx: transactionId, files: 0, working: false, error: "" });
await onUndoRewind(transactionId);
if (owner.notice?.tx === transactionId && owner.notice.error) throw new Error(owner.notice.error);
}, [owner, publish, onUndoRewind]);
const dismissRestore = useCallback(() => { if (!owner.notice?.working) publish(null); }, [owner, publish]);
const onPrepareFileRevert = useCallback((path: string) => port.prepareFileRevert(path), [port]);
const onCommitFileRevert = useCallback(async (planId: string, resolution?: string) => {
@@ -110,5 +118,5 @@ export function useRewindActions(port: AgentPort, reloadSession: (current?: () =
if (owner.active && owner.epoch === epoch && result.transactionId) publish(null);
return result;
}, [port, owner, publish]);
return { reloadFailure: reloadFailure?.owner === owner ? reloadFailure : null, onReloadSession, restoreNotice: notice?.owner === owner ? notice.value : null, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
return { onUndoAvailable, reloadFailure: reloadFailure?.owner === owner ? reloadFailure : null, onReloadSession, restoreNotice: notice?.owner === owner ? notice.value : null, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
}
+2
View File
@@ -155,6 +155,8 @@ const (
// ProgressWatchEvent reports whether the run is still producing effects the
// host can observe (ProgressWatch). Said to the person, never to the model.
ProgressWatchEvent
// RewindChanged signals that the undo offer must be read again.
RewindChanged
// KindCount is a sentinel one past the last real Kind. New event kinds must
// be inserted above it so completeness tests cover them automatically.
KindCount
+61
View File
@@ -0,0 +1,61 @@
package eventwire
import "reasonix/internal/contract/event"
// KindNames returns every stable frontend event kind in event.Kind order. It is
// the protocol-neutral source used by consumers such as the Remote schema
// generator; callers receive a copy and may sort it without mutating eventwire.
func KindNames() []string {
names := make([]string, 0, int(event.KindCount))
for kind := range event.KindCount {
if name, ok := kindNames[kind]; ok {
names = append(names, name)
}
}
return names
}
// KindName returns the stable wire name of one event kind, or false for a
// kind outside the known set.
func KindName(kind event.Kind) (string, bool) {
name, ok := kindNames[kind]
return name, ok
}
var kindNames = map[event.Kind]string{
event.TurnStarted: "turn_started",
event.Reasoning: "reasoning",
event.Text: "text",
event.Message: "message",
event.ToolDispatch: "tool_dispatch",
event.ToolResult: "tool_result",
event.Usage: "usage",
event.Notice: "notice",
event.Phase: "phase",
event.ApprovalRequest: "approval_request",
event.AskRequest: "ask_request",
event.TurnDone: "turn_done",
event.CompactionStarted: "compaction_started",
event.CompactionProgress: "compaction_progress",
event.CompactionDone: "compaction_done",
event.ToolProgress: "tool_progress",
event.MCPSurfaceReady: "mcp_surface_ready",
event.Retrying: "retrying",
event.Steer: "steer",
event.GuardianAssessment: "guardian_assessment",
event.ExtensionSurface: "extension_surface",
event.ExtensionStatus: "extension_status",
event.StreamAttempt: "stream_attempt",
event.ContextMaintenanceEvent: "context_maintenance",
event.TodoProgressEvent: "todo_progress",
event.WorkspaceLeaseEvent: "workspace_lease",
event.WorkspaceChanged: "workspace_changed",
event.TurnPhase: "turn_phase",
event.CompletionSummary: "completion_summary",
event.AdjudicationsChanged: "adjudications_changed",
event.BrowserTabsChanged: "browser_tabs_changed",
event.InboxChanged: "inbox_changed",
event.RewindChanged: "rewind_changed",
event.GraphDelta: "graph_delta",
event.ProgressWatchEvent: "progress_watch",
}
-57
View File
@@ -701,63 +701,6 @@ func ToWireCacheDiagnostics(d *event.CacheDiagnostics) *CacheDiagnostics {
}
}
// KindNames returns every stable frontend event kind in event.Kind order. It is
// the protocol-neutral source used by consumers such as the Remote schema
// generator; callers receive a copy and may sort it without mutating eventwire.
func KindNames() []string {
names := make([]string, 0, int(event.KindCount))
for kind := range event.KindCount {
if name, ok := kindNames[kind]; ok {
names = append(names, name)
}
}
return names
}
// KindName returns the stable wire name of one event kind, or false for a
// kind outside the known set.
func KindName(kind event.Kind) (string, bool) {
name, ok := kindNames[kind]
return name, ok
}
var kindNames = map[event.Kind]string{
event.TurnStarted: "turn_started",
event.Reasoning: "reasoning",
event.Text: "text",
event.Message: "message",
event.ToolDispatch: "tool_dispatch",
event.ToolResult: "tool_result",
event.Usage: "usage",
event.Notice: "notice",
event.Phase: "phase",
event.ApprovalRequest: "approval_request",
event.AskRequest: "ask_request",
event.TurnDone: "turn_done",
event.CompactionStarted: "compaction_started",
event.CompactionProgress: "compaction_progress",
event.CompactionDone: "compaction_done",
event.ToolProgress: "tool_progress",
event.MCPSurfaceReady: "mcp_surface_ready",
event.Retrying: "retrying",
event.Steer: "steer",
event.GuardianAssessment: "guardian_assessment",
event.ExtensionSurface: "extension_surface",
event.ExtensionStatus: "extension_status",
event.StreamAttempt: "stream_attempt",
event.ContextMaintenanceEvent: "context_maintenance",
event.TodoProgressEvent: "todo_progress",
event.WorkspaceLeaseEvent: "workspace_lease",
event.WorkspaceChanged: "workspace_changed",
event.TurnPhase: "turn_phase",
event.CompletionSummary: "completion_summary",
event.AdjudicationsChanged: "adjudications_changed",
event.BrowserTabsChanged: "browser_tabs_changed",
event.InboxChanged: "inbox_changed",
event.GraphDelta: "graph_delta",
event.ProgressWatchEvent: "progress_watch",
}
func toWireTodoProgress(p *event.TodoProgress) *TodoProgress {
if p == nil {
return nil
@@ -0,0 +1,13 @@
package serve
import "net/http"
// rewindAvailableUndo reads the session's currently retained undo offer.
func (s *Server) rewindAvailableUndo(w http.ResponseWriter, r *http.Request) {
offer, err := s.ctl().AvailableUndo()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, offer)
}
@@ -0,0 +1,41 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reasonix/internal/session/control"
"reasonix/internal/state/checkpoint"
"testing"
)
type undoReadPort struct {
control.SessionAPI
offer *checkpoint.RewindUndo
}
func (p *undoReadPort) AvailableUndo() (*checkpoint.RewindUndo, error) { return p.offer, nil }
func TestRewindAvailableUndoRoute(t *testing.T) {
for _, offer := range []*checkpoint.RewindUndo{nil, {TransactionID: "saved", Turn: 2, Files: 3}} {
s := &Server{ctrl: &undoReadPort{offer: offer}}
w := httptest.NewRecorder()
s.routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/rewind/undo", nil))
if w.Code != http.StatusOK {
t.Fatalf("status: %d, %s", w.Code, w.Body.String())
}
var got *checkpoint.RewindUndo
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if offer == nil {
if got != nil {
t.Fatalf("empty offer: %+v", got)
}
continue
}
if got == nil || *got != *offer {
t.Fatalf("offer: %+v, want %+v", got, offer)
}
}
}
+1
View File
@@ -58,6 +58,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /rewind/prepare", s.rewindPrepare)
mux.HandleFunc("POST /rewind/commit", s.rewindCommit)
mux.HandleFunc("POST /rewind/undo", s.rewindUndo)
mux.HandleFunc("GET /rewind/undo", s.rewindAvailableUndo)
mux.HandleFunc("POST /rewind/file/prepare", s.fileRevertPrepare)
mux.HandleFunc("POST /rewind/file/commit", s.fileRevertCommit)
mux.HandleFunc("POST /summarize", s.summarize)
+3
View File
@@ -296,6 +296,9 @@ func ckptDir(sessionPath string) string {
func (c *Controller) rebindCheckpoints(sessionPath string) {
c.goals.setStatePath(goalStatePath(sessionPath))
c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot)
c.checkpoints.storeRef().OnUndoChange(func() {
c.sink.Emit(event.Event{Kind: event.RewindChanged})
})
if c.executor != nil {
c.wireMutationObserver()
}
+1
View File
@@ -155,6 +155,7 @@ type SessionHistory interface {
Rewind(turn int, scope RewindScope) error
PrepareRewind(turn int, scope RewindScope) (checkpoint.RewindPlan, error)
CommitRewind(planID string) (checkpoint.RewindResult, error)
AvailableUndo() (*checkpoint.RewindUndo, error)
UndoRewind(transactionID string) (checkpoint.RewindResult, error)
PrepareFileRevert(path string) (checkpoint.RewindPlan, error)
CommitFileRevert(planID string, resolution checkpoint.ConflictResolution) (checkpoint.RewindResult, error)
@@ -0,0 +1,11 @@
package control
import "reasonix/internal/state/checkpoint"
// AvailableUndo is a read: it neither takes the rotation gate nor scans files.
func (c *Controller) AvailableUndo() (*checkpoint.RewindUndo, error) {
if c.Running() {
return nil, nil
}
return c.checkpoints.storeRef().AvailableUndo()
}
@@ -0,0 +1,83 @@
package control
import (
"context"
"os"
"path/filepath"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
"reasonix/internal/state/checkpoint"
"reasonix/internal/state/sessionstore"
"testing"
)
func TestAvailableUndoAndChangeEvents(t *testing.T) {
root, dir := testenv.TempDir(t), testenv.TempDir(t)
path := filepath.Join(root, "note.txt")
if err := os.WriteFile(path, []byte("before"), 0644); err != nil {
t.Fatal(err)
}
sess := sessionstore.NewSession("sys")
ag := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
changes := make(chan event.Event, 8)
c := New(Options{Runner: ag, Executor: ag, SessionDir: dir,
SessionPath: filepath.Join(dir, "test.jsonl"), WorkspaceRoot: root,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.RewindChanged {
changes <- e
}
}),
})
t.Cleanup(c.Close)
c.beginCheckpoint(context.Background(), "edit")
c.mutationObserver.BeforeMutation("note.txt", "write_file", checkpoint.CaptureBeforeMutation)
if err := os.WriteFile(path, []byte("after"), 0644); err != nil {
t.Fatal(err)
}
c.mutationObserver.AfterMutation("note.txt", "write_file")
plan, err := c.PrepareRewind(0, RewindCode)
if err != nil {
t.Fatal(err)
}
result, err := c.CommitRewind(plan.PlanID)
if err != nil || !result.OK {
t.Fatalf("commit: %+v, %v", result, err)
}
select {
case e := <-changes:
if e.Text != "" {
t.Fatalf("event has content: %+v", e)
}
default:
t.Fatal("commit did not emit change")
}
if err := c.beginRotation(); err != nil {
t.Fatal(err)
}
offer, err := c.AvailableUndo()
c.endRotation()
if err != nil || offer == nil || offer.TransactionID != result.TransactionID {
t.Fatalf("rotation read: %+v, %v", offer, err)
}
b := c.checkpoints.storeRef().Barrier()
if err := b.EnterWrite(); err != nil {
t.Fatal(err)
}
offer, err = c.AvailableUndo()
b.ExitWrite()
if err != nil || offer != nil {
t.Fatalf("writer read: %+v, %v", offer, err)
}
c.beginCheckpoint(context.Background(), "next turn")
select {
case <-changes:
default:
t.Fatal("new turn did not emit invalidation")
}
offer, err = c.AvailableUndo()
if err != nil || offer != nil {
t.Fatalf("invalidated read: %+v, %v", offer, err)
}
}
+10
View File
@@ -131,6 +131,16 @@ func (b *MutationBarrier) ExitExclusive() {
b.cond.Broadcast()
}
// HasWriters reads writer activity without taking exclusive mutation access.
func (b *MutationBarrier) HasWriters() bool {
if b == nil {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
return b.writers > 0
}
// Busy reports whether exclusive is held or writers are active.
func (b *MutationBarrier) Busy() bool {
if b == nil {
+4 -16
View File
@@ -140,6 +140,7 @@ type Store struct {
activeWriters []ActiveWriter
plans map[string]preparedPlan
lastUndo *TransactionManifest
undoChanged func()
sessionID string
mutationSeq int64
retainN int
@@ -224,27 +225,14 @@ func (s *Store) activeWriterConflicts() []RewindConflict {
return conflicts
}
// LastUndoTransactionID returns the committed transaction id available for undo.
func (s *Store) LastUndoTransactionID() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
if s.lastUndo == nil || s.lastUndo.State != TxCommitted {
return ""
}
return s.lastUndo.ID
}
// InvalidateUndo clears the last undo slot (new turn / new mutation / new rewind).
func (s *Store) InvalidateUndo() {
if s == nil {
return
}
s.mu.Lock()
defer s.unlockUndoChange(s.lastUndo)
s.lastUndo = nil
s.mu.Unlock()
}
func (s *Store) load() {
@@ -309,7 +297,7 @@ func (s *Store) load() {
// prompt labels it in the picker; msgIndex is the conversation-rewind boundary.
func (s *Store) Begin(turn int, prompt string, msgIndex int) {
s.mu.Lock()
defer s.mu.Unlock()
defer s.unlockUndoChange(s.lastUndo)
if s.cur != nil {
s.recomputeCoverageLocked(s.cur)
s.done = append(s.done, s.cur)
@@ -501,7 +489,7 @@ func (s *Store) CaptureAfter(path string, opts CaptureAfterOpts) {
_ = err
s.mu.Lock()
defer s.mu.Unlock()
defer s.unlockUndoChange(s.lastUndo)
s.mutationSeq = opts.Seq
if s.cur != nil {
s.cur.LastMutationSeq = opts.Seq
+6 -2
View File
@@ -95,16 +95,19 @@ func (s *Store) finishOwnedUndo(undo, original *TransactionManifest, applier Con
s.cleanupCommittedBackups(undo.Targets)
// Mark original as undone; clear lastUndo.
s.mu.Lock()
original.State = TxUndone
original.UpdatedAt = time.Now()
s.mu.Unlock()
if err := s.persistTransaction(original); err != nil {
// The committed undo manifest durably names its parent, so startup will
// suppress the stale parent even if this secondary write failed.
slog.Warn("checkpoint: persist original transaction as undone", "err", err)
}
s.mu.Lock()
previous := s.lastUndo
s.lastUndo = nil
s.mu.Unlock()
s.unlockUndoChange(previous)
result.OK = true
result.UndoAvailable = false
@@ -151,8 +154,9 @@ func (s *Store) finishOwnedRewind(tx *TransactionManifest, applier ConversationA
}
s.cleanupCommittedBackups(tx.Targets)
s.mu.Lock()
previous := s.lastUndo
s.lastUndo = tx
s.mu.Unlock()
s.unlockUndoChange(previous)
result.OK = true
result.UndoAvailable = true
+1 -7
View File
@@ -1197,13 +1197,7 @@ func (s *Store) recoverTransactions(applier ConversationApplier) []string {
if tx.Kind == "undo" || undoneParents[tx.ID] {
continue
}
// Keep as last undo if newer.
s.mu.Lock()
if s.lastUndo == nil || s.lastUndo.UpdatedAt.Before(tx.UpdatedAt) {
cp := tx
s.lastUndo = &cp
}
s.mu.Unlock()
s.restoreUndoOffer(tx)
}
}
return notes
+7
View File
@@ -191,6 +191,13 @@ type RewindPlan struct {
ConflictResolution string `json:"conflictResolution,omitempty"`
}
// RewindUndo describes the currently retained undo offer.
type RewindUndo struct {
TransactionID string `json:"transactionId"`
Turn int `json:"turn"`
Files int `json:"files"`
}
// RewindResult is returned after commit or undo.
type RewindResult struct {
OK bool `json:"ok"`
+59
View File
@@ -0,0 +1,59 @@
package checkpoint
// OnUndoChange installs the owner's content-free notification callback.
func (s *Store) OnUndoChange(notify func()) {
s.mu.Lock()
s.undoChanged = notify
s.mu.Unlock()
}
// AvailableUndo reads the saved offer without disk I/O or file verification.
func (s *Store) AvailableUndo() (*RewindUndo, error) {
if s == nil || s.barrier.HasWriters() {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
tx := s.lastUndo
if tx == nil || tx.State != TxCommitted {
return nil, nil
}
return &RewindUndo{TransactionID: tx.ID, Turn: tx.Turn, Files: len(tx.Targets)}, nil
}
// Callbacks may read the offer, so release the lock before notifying.
func (s *Store) unlockUndoChange(previous *TransactionManifest) {
before, after := "", ""
if previous != nil {
before = previous.ID
}
if s.lastUndo != nil {
after = s.lastUndo.ID
}
notify := s.undoChanged
s.mu.Unlock()
if before != after && notify != nil {
notify()
}
}
// LastUndoTransactionID returns the committed transaction id available for undo.
func (s *Store) LastUndoTransactionID() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
if s.lastUndo == nil || s.lastUndo.State != TxCommitted {
return ""
}
return s.lastUndo.ID
}
func (s *Store) restoreUndoOffer(tx TransactionManifest) {
s.mu.Lock()
defer s.unlockUndoChange(s.lastUndo)
if s.lastUndo == nil || s.lastUndo.UpdatedAt.Before(tx.UpdatedAt) {
s.lastUndo = &tx
}
}
@@ -0,0 +1,54 @@
package checkpoint
import "testing"
func TestUndoOfferChangeLifecycle(t *testing.T) {
for _, action := range []string{"undo", "begin", "mutation", "invalidate"} {
t.Run(action, func(t *testing.T) {
s, _, _, path := twoOwnedTurns(t)
var offers []*RewindUndo
s.OnUndoChange(func() {
offer, err := s.AvailableUndo()
if err != nil {
t.Error(err)
}
offers = append(offers, offer)
})
result := commitOwnedRewind(t, s, 1)
if len(offers) != 1 || offers[0] == nil || offers[0].TransactionID != result.TransactionID {
t.Fatalf("commit offers: %+v", offers)
}
switch action {
case "undo":
if _, err := s.UndoRewind(result.TransactionID, nil); err != nil {
t.Fatal(err)
}
case "begin":
s.Begin(2, "next turn", 2)
case "mutation":
write(t, path, "external writer")
s.CaptureAfter(path, CaptureAfterOpts{Seq: 3})
case "invalidate":
s.InvalidateUndo()
}
s.InvalidateUndo()
if len(offers) != 2 || offers[1] != nil {
t.Fatalf("invalidation offers: %+v", offers)
}
})
}
}
func TestFailedRewindDoesNotSignalUndoChange(t *testing.T) {
s, _, _, _ := twoOwnedTurns(t)
commitOwnedRewind(t, s, 1)
changes := 0
s.OnUndoChange(func() { changes++ })
plan := prepareOwnedRewind(t, s, 0)
if _, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, &InjectFail{Phase: "finalize"}); err == nil {
t.Fatal("expected injected failure")
}
if changes != 0 {
t.Fatalf("failed rewind sent %d changes", changes)
}
}
+1
View File
@@ -162,6 +162,7 @@ var mirroredWireTypes = []wireMirror{
{"internal/frontend/serve/studio_logs.go", "studioLogs", tsVersionFile, "StudioLogs"},
{"internal/frontend/serve/studio_logs.go", "studioCrashes", tsVersionFile, "StudioCrash"},
{"internal/state/checkpoint/types.go", "RewindResult", tsSessionFile, "RewindResult"},
{"internal/state/checkpoint/types.go", "RewindUndo", tsSessionFile, "RewindUndo"},
{"internal/contract/eventwire/wire.go", "ShellExecution", tsWireFile, "Execution"},
{"internal/contract/eventwire/workspace_lease.go", "WorkspaceLease", tsWireFile, "WorkspaceLease"},
// A source and a model as the pickers name them. A label the page cannot