fix(studio): restore rewound prompts to the composer

This commit is contained in:
xiexian committed 2026-10-08 08:04:33 +08:00
1 parent e4aafd6d8e
commit 26bf6547d9
6 files changed
+220 -12

No files matched your search

+2 -3
View File
@@ -307,8 +307,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
return history;
}, [port, applyStatus, refreshWallet, onSessionChanged, replayTrajectory]);
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession);
// Both of these read only the user and tool cards, so they key off the
// revision rather than the items array: a streamed answer leaves every card
// they look at untouched, and recomputing them per chunk is the whole reason
@@ -431,13 +429,14 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
[port, running, refreshStatus, fail],
);
const { queue, restored, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
const { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel } = useQueueActions({
port,
dispatch,
fail,
moved: s.queueMoved,
sessionPath: status?.sessionPath,
});
const { onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText);
// The kernel refuses a submit it cannot start with a code, not a sentence:
// the words are fine, the timing is not. Queueing them is what that code
+1 -1
View File
@@ -72,7 +72,7 @@ interface Props {
onPrepareRewind: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
onPrepareFileRevert: (path: string) => Promise<RewindPlan>;
onCommitFileRevert: (planId: string, resolution?: string) => Promise<RewindResult>;
onCommitRewind: (planId: string) => Promise<RewindResult>;
onCommitRewind: (planId: string, text?: string) => Promise<RewindResult>;
onUndoRewind: (transactionId: string) => Promise<void>;
/** Cards that have not had their one entrance yet. Owed by the projection,
* spent by the first render that draws them — never by the animation, which
@@ -22,7 +22,7 @@ export function UserCard({
cp?: Checkpoint;
onResend?: (turn: number, text: string) => Promise<void>;
onPrepareRewind?: (turn: number, scope: RewindScope) => Promise<RewindPlan>;
onCommitRewind?: (planId: string) => Promise<RewindResult>;
onCommitRewind?: (planId: string, text?: string) => Promise<RewindResult>;
onUndoRewind?: (transactionId: string) => Promise<void>;
}) {
// A rewind needs a turn the kernel claimed, and a queued line has not
@@ -122,7 +122,7 @@ export function UserCard({
</button>
)}
{rewind && (
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={onCommitRewind!} onUndo={onUndoRewind!} />
<RewindControl cp={cp!} compact onPrepare={onPrepareRewind!} onCommit={(planId) => onCommitRewind!(planId, item.text)} onUndo={onUndoRewind!} />
)}
</div>
</div>
+4 -3
View File
@@ -20,6 +20,7 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const [queue, setQueue] = useState<QueueSnapshot | null>(null);
// The last line taken back, numbered so the same text twice is two requests.
const [restored, setRestored] = useState({ n: 0, text: "" });
const onRestoreText = useCallback((text: string) => setRestored((r) => ({ n: r.n + 1, text })), []);
// The queue as the kernel holds it. The frame says only that it moved, so the
// answer is read back whole — which is also what puts another window's lines,
// and the CLI's, in front of this one. The optimistic rows say only what was
@@ -73,13 +74,13 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const text = await port.readQueued(itemId);
await port.cancelQueued(itemId);
dispatch({ kind: "__unsent", id: itemId } as never);
setRestored((r) => ({ n: r.n + 1, text }));
onRestoreText(text);
} catch (e) {
taking.current.delete(itemId);
fail(e);
}
},
[port, fail, dispatch],
[port, fail, dispatch, onRestoreText],
);
return { queue, restored, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel };
return { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel };
}
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
import "./testkit";
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Pane } from "./Pane";
import { MockPort } from "../port/mock";
import type { HistoryMessage, RewindResult } from "../port/port";
import type { RuntimeView } from "../port/hub";
import { useRewindActions } from "./rewind";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
const BODY = "@.reasonix/attachments/clipboard-1.png Review this image\n Keep the indentation";
const HISTORY: HistoryMessage[] = [
{ role: "user", content: BODY, msgIndex: 0 },
{ role: "assistant", content: "The image has been reviewed.", msgIndex: 1 },
];
const rt = { id: "p1", root: "/sample", name: "Sample" } as RuntimeView;
const props = {
rt, 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() {},
};
const box = () => document.querySelector<HTMLTextAreaElement>('textarea[aria-label="任务输入"]')!;
const type = (text: string) => fireEvent.change(box(), { target: { value: text, selectionStart: text.length } });
async function open(result: RewindResult = { ok: true, conversationOk: true }, original = HISTORY) {
const port = new MockPort();
let history = original;
const reads = vi.spyOn(port, "history").mockImplementation(async () => history);
vi.spyOn(port, "checkpoints").mockImplementation(async () => history.filter((m) => m.role === "user").map((m, i) => ({
turn: i + 1, prompt: "Review this image", files: 2, msgIndex: m.msgIndex,
})));
vi.spyOn(port, "prepareRewind").mockImplementation(async (turn) => ({
planId: `plan-${turn}`, turn, coverage: "full", canFiles: true, canConversation: true,
fileCount: 2, requiresConfirmation: false,
}));
vi.spyOn(port, "commitRewind").mockImplementation(async (planId) => {
if (result.conversationOk) history = history.slice(0, (Number(planId.slice(5)) - 1) * 2);
return result;
});
vi.spyOn(port, "undoRewind").mockResolvedValue();
vi.spyOn(port, "submit").mockResolvedValue({ itemId: "sent-1", disposition: "queued_followup" });
render(<Pane {...props} port={port} />);
await screen.findAllByRole("button", { name: "回到这里" });
return { port, reads };
}
async function rewind(label = "只回退对话") {
fireEvent.click(screen.getAllByRole("button", { name: "回到这里" }).at(-1)!);
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(label) }));
}
describe("restoring a rewound prompt to the composer", () => {
it.each(["", BODY, "An existing draft "])("restores the complete original beside %j", async (draft) => {
const { port, reads } = await open();
type(draft);
const before = reads.mock.calls.length;
await rewind();
await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(before));
await waitFor(() => expect(screen.queryByRole("button", { name: "回到这里" })).toBeNull());
expect(port.prepareRewind).toHaveBeenCalledWith(1, "conversation");
expect(port.commitRewind).toHaveBeenCalledExactlyOnceWith("plan-1");
await waitFor(() => expect(box().value).toBe(draft && draft !== BODY ? `An existing draft\n${BODY}` : BODY));
await waitFor(() => expect(document.activeElement).toBe(box()));
});
it("also restores when code and conversation are rewound together", async () => {
const { port } = await open();
await rewind("代码和对话");
await waitFor(() => expect(box().value).toBe(BODY));
expect(port.prepareRewind).toHaveBeenCalledWith(1, "both");
});
it("uses the reported conversation outcome instead of aggregate ok", async () => {
await open({ ok: false, conversationOk: true });
await rewind("代码和对话");
await waitFor(() => expect(box().value).toBe(BODY));
});
it.each([false, undefined])("keeps a draft when conversationOk is %j", async (conversationOk) => {
const { reads } = await open({ ok: true, conversationOk });
type("Keep my draft");
const before = reads.mock.calls.length;
await rewind();
await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(before));
expect(box().value).toBe("Keep my draft");
expect(screen.getByRole("button", { name: "回到这里" })).toBeTruthy();
});
it("keeps the draft through a code-only rewind", async () => {
const { port, reads } = await open({ ok: true, conversationOk: false });
type("Keep my draft");
const before = reads.mock.calls.length;
await rewind("只还原代码");
await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(before));
expect(port.prepareRewind).toHaveBeenCalledWith(1, "code");
expect(box().value).toBe("Keep my draft");
});
it("does not restore or reload after a rejected commit", async () => {
const { port, reads } = await open();
vi.mocked(port.commitRewind).mockRejectedValue(new Error("checkpoint is gone"));
type("Keep my draft");
const before = reads.mock.calls.length;
await rewind();
await screen.findByText("checkpoint is gone");
expect(box().value).toBe("Keep my draft");
expect(reads.mock.calls).toHaveLength(before);
});
it("keeps the draft when preparation fails", async () => {
const { port } = await open();
vi.mocked(port.prepareRewind).mockRejectedValue(new Error("cannot prepare"));
type("Keep my draft");
await rewind();
await screen.findByText("cannot prepare");
expect(port.commitRewind).not.toHaveBeenCalled();
expect(box().value).toBe("Keep my draft");
});
it.each([false, true])("restores only after a confirmed commit: %j", async (confirm) => {
const { port } = await open();
vi.mocked(port.prepareRewind).mockResolvedValue({
planId: "plan-1", turn: 1, coverage: "partial", canFiles: true, canConversation: true,
fileCount: 2, requiresConfirmation: true,
});
type("Keep my draft");
await rewind("代码和对话");
await screen.findByRole("menuitem", { name: /仍还原其余部分/ });
expect(box().value).toBe("Keep my draft");
expect(port.commitRewind).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("menuitem", { name: confirm ? /仍还原其余部分/ : "取消" }));
if (confirm) await waitFor(() => expect(box().value).toBe(`Keep my draft\n${BODY}`));
else {
expect(port.commitRewind).not.toHaveBeenCalled();
expect(box().value).toBe("Keep my draft");
}
});
it("keeps text entered while the commit is pending", async () => {
const { port } = await open();
let resolve!: (result: RewindResult) => void;
vi.mocked(port.commitRewind).mockReturnValue(new Promise((done) => { resolve = done; }));
await rewind();
await waitFor(() => expect(port.commitRewind).toHaveBeenCalled());
expect(box().value).toBe("");
type("Written while waiting");
await act(async () => resolve({ ok: true, conversationOk: true }));
await waitFor(() => expect(box().value).toBe(`Written while waiting\n${BODY}`));
});
it("can restore the same prompt again without replaying an old request", async () => {
const { port } = await open({ ok: true, conversationOk: true }, [
...HISTORY, ...HISTORY.map((m) => ({ ...m, msgIndex: m.msgIndex! + 2 })),
]);
await rewind();
await waitFor(() => expect(box().value).toBe(BODY));
await waitFor(() => expect(screen.getAllByRole("button", { name: "回到这里" })).toHaveLength(1));
type("");
await rewind();
await waitFor(() => expect(box().value).toBe(BODY));
expect(port.commitRewind).toHaveBeenCalledTimes(2);
});
it("lets the restored attachment reference be edited and submitted", async () => {
const { port } = await open();
await rewind();
await waitFor(() => expect(box().value).toBe(BODY));
const edited = BODY.replace("Review this image", "Explain this image");
type(edited);
fireEvent.keyDown(box(), { key: "Enter" });
await waitFor(() => expect(port.submit).toHaveBeenCalledWith(edited, undefined));
expect(box().value).toBe("");
});
it("keeps the composer draft when a card is edited and resent", async () => {
const { port } = await open();
type("Separate draft");
fireEvent.click(screen.getByRole("button", { name: "改写" }));
const edit = screen.getByRole("textbox", { name: "改写这条消息" });
fireEvent.change(edit, { target: { value: "A revised prompt" } });
fireEvent.click(screen.getByRole("button", { name: "改完重发" }));
await waitFor(() => expect(port.submit).toHaveBeenCalledWith("A revised prompt", undefined));
expect(box().value).toBe("Separate draft");
});
it("leaves restoration out of undo, single-file revert and commits without a prompt", async () => {
const port = new MockPort();
vi.spyOn(port, "commitRewind").mockResolvedValue({ ok: true, conversationOk: true });
const undo = vi.spyOn(port, "undoRewind").mockResolvedValue();
const revert = vi.spyOn(port, "commitFileRevert").mockResolvedValue({ ok: true });
const reload = vi.fn();
const restore = vi.fn();
const { result } = renderHook(() => useRewindActions(port, reload, restore));
await act(async () => { await result.current.onCommitRewind("plan-1"); });
expect(reload).toHaveBeenCalledTimes(1);
await act(async () => { await result.current.onUndoRewind("tx-1"); });
expect(undo).toHaveBeenCalledExactlyOnceWith("tx-1");
expect(reload).toHaveBeenCalledTimes(2);
await act(async () => { await result.current.onCommitFileRevert("file-plan", "overwrite"); });
expect(revert).toHaveBeenCalledExactlyOnceWith("file-plan", "overwrite");
expect(reload).toHaveBeenCalledTimes(2);
expect(restore).not.toHaveBeenCalled();
});
});
+4 -3
View File
@@ -5,15 +5,16 @@ import type { AgentPort, RewindScope } from "../port/port";
* transcript and the files under it, so the whole session is re-read the way a
* session switch is. Reverting one file touches disk but not the transcript,
* so it reloads nothing. */
export function useRewindActions(port: AgentPort, reloadSession: () => void) {
export function useRewindActions(port: AgentPort, reloadSession: () => void, onRestoreText: (text: string) => void) {
const onPrepareRewind = useCallback((turn: number, scope: RewindScope) => port.prepareRewind(turn, scope), [port]);
const onCommitRewind = useCallback(
async (planId: string) => {
async (planId: string, text?: string) => {
const result = await port.commitRewind(planId);
if (result.conversationOk && text !== undefined) onRestoreText(text);
reloadSession();
return result;
},
[port, reloadSession],
[port, reloadSession, onRestoreText],
);
const onUndoRewind = useCallback((transactionId: string) => port.undoRewind(transactionId).then(reloadSession), [port, reloadSession]);
const onPrepareFileRevert = useCallback((path: string) => port.prepareFileRevert(path), [port]);