Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function HomePage() {
const { isDesktopRuntime, backendStatus, backendMessage, showPackagedBackendStatus } = useTauriBackend(editor);
const projectRoot = project.projectRoot;
const fileIO = useFileIO({ editor, isDesktopRuntime, backendStatus, monacoRef });
const { selectedText, syncSelectedText, applyAction: handleAIApplyAction, executePrompt: executeAIPrompt, executeWork, isWorking } = useAIActions({
const { selectedText, syncSelectedText, applyAction: handleAIApplyAction, executePrompt: executeAIPrompt, executeWork, isWorking, undoWork, canUndoWork } = useAIActions({
documentId: editor.activeTab.id, documentText: editor.activeTab.content, editorRef: monacoRef, onDocumentChange: editor.handleContentChange,
});
const selectPanel = useCallback((panel: NonNullable<typeof PANELS[keyof typeof PANELS]>) => {
Expand Down Expand Up @@ -191,6 +191,8 @@ export default function HomePage() {
initialTab={aiPanelInitialTab}
executeWork={executeWork}
isWorking={isWorking}
undoWork={undoWork}
canUndoWork={canUndoWork}
/>
),
[PANELS.CITATION]: (
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type Props = {
initialTab?: Tab;
executeWork?: (instruction: string) => Promise<void>;
isWorking?: boolean;
/** Revert the last AI Work result applied to the active document. */
undoWork?: () => void;
canUndoWork?: boolean;
};

const LANGUAGES = [
Expand Down Expand Up @@ -172,6 +175,8 @@ export default function AIPanel({
initialTab,
executeWork,
isWorking,
undoWork,
canUndoWork,
}: Props) {
const currentDocumentRef = useRef({ documentId, documentText });
currentDocumentRef.current = { documentId, documentText };
Expand Down Expand Up @@ -929,6 +934,14 @@ export default function AIPanel({
>
{isWorking || projectWork.busy ? "Working..." : workScope === "project" ? "Plan Project Changes" : "Apply Changes"}
</button>
{workScope === "document" && canUndoWork && undoWork && (
<button
className="w-full py-1.5 px-3 rounded text-sm border border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-[#2d2d2d]"
onClick={undoWork}
>
Undo AI Changes
</button>
)}
</div>
) : tab === "translate" ? (
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-3">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ProjectWorkReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default function ProjectWorkReview({ projectRoot, work }: Props) {
<p className="truncate text-gray-500 dark:text-gray-400" title={projectRoot}>Project: {projectRoot}</p>
<p className="text-gray-500 dark:text-gray-400">The AI discovers paths first and reads only files it requests. It cannot write to disk.</p>
{work.message && <p role="status" className="whitespace-pre-wrap">{work.message}</p>}
{work.canUndo && <button onClick={work.undo} className="rounded border px-2 py-1 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-[#2d2d2d]">Undo accepted changes</button>}
{work.task && <div className="space-y-2 border-t pt-2 dark:border-gray-700">
<p>{work.task.summary}</p>
<p className="text-gray-500 dark:text-gray-400">{work.task.file_count} files · {work.task.steps} steps · {work.task.inspected.length} inspected</p>
Expand Down
25 changes: 23 additions & 2 deletions frontend/src/hooks/useAIActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useLayoutEffect, useRef, useState, type RefObject } from "react";
import type { editor as MonacoEditor } from "monaco-editor";
import { AI } from "@/lib/api";
import { applyWorkResultIfFresh } from "@/lib/work-result-guard.mjs";
import { applyWorkResultIfFresh, isWorkResultFresh } from "@/lib/work-result-guard.mjs";
import { applyWorkEdit } from "@/lib/apply-work-edit.mjs";

type UseAIActionsOptions = {
Expand All @@ -21,6 +21,8 @@ export function useAIActions({
}: UseAIActionsOptions) {
const [selectedText, setSelectedText] = useState("");
const [isWorking, setIsWorking] = useState(false);
// The last AI Work result applied here, so the Work panel can offer to undo it.
const [lastWork, setLastWork] = useState<{ documentId: string; before: string; after: string } | null>(null);
const currentDocumentRef = useRef({ documentId, content: documentText });

useLayoutEffect(() => {
Expand Down Expand Up @@ -121,7 +123,9 @@ export function useAIActions({
// preserves the old behaviour when no editor/model is available.
(content) => applyWorkEdit(editorRef.current, onDocumentChange, content)
);
if (!applied) {
if (applied) {
setLastWork({ documentId, before: requestSnapshot.content, after: data.modified_content });
} else {
alert(
"The document changed while AI Work was running. No AI changes were applied. Run the task again on the current document."
);
Expand All @@ -134,12 +138,29 @@ export function useAIActions({
}
}, [documentId, documentText, editorRef, onDocumentChange]);

const undoWork = useCallback(() => {
if (!lastWork) return;
const stillAIResult = isWorkResultFresh(
{ documentId: lastWork.documentId, content: lastWork.after },
currentDocumentRef.current
);
if (!stillAIResult) {
alert("The document was edited after AI Work was applied. Use Undo (Cmd/Ctrl+Z) in the editor to step back through those edits.");
return;
}
// Restored as its own undoable edit, so editor Undo (Cmd/Ctrl+Z) can bring the AI result back.
applyWorkEdit(editorRef.current, onDocumentChange, lastWork.before);
setLastWork(null);
}, [editorRef, lastWork, onDocumentChange]);

return {
selectedText,
syncSelectedText,
applyAction,
executePrompt,
executeWork,
isWorking,
undoWork,
canUndoWork: !isWorking && lastWork?.documentId === documentId,
};
}
15 changes: 15 additions & 0 deletions frontend/src/hooks/useEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { resolveTabClose } from "@/lib/tabLifecycle.mjs";
import { settleSavedTab } from "@/lib/save-settle.mjs";
import { parentDirOf, resolvePreviewBaseDir } from "@/lib/preview-base-dir.mjs";
import { createLatestRequestGuard } from "@/lib/latest-request.mjs";
import { revertProjectWork, type ProjectWorkUndoEntry } from "@/lib/project-work-undo.mjs";

export type Tab = {
id: string;
Expand Down Expand Up @@ -391,6 +392,19 @@ export function useEditor() {
if (activeChange) refreshPreview(activeChange.content, activeChange.file_path);
}, [activeTab.filePath, refreshPreview]);

/** Revert an accepted Project Work result; returns the paths left untouched. */
const revertProjectChanges = useCallback((record: ProjectWorkUndoEntry[]) => {
const { tabs: reverted, skipped } = revertProjectWork(tabs, record);
const remaining = reverted.length ? reverted : [makeTab(nextTabId())];
const active = remaining.find((tab) => tab.id === activeTabId) ?? remaining[remaining.length - 1];
setTabs(remaining);
setActiveTabId(active.id);
if (active.id !== activeTabId || active.content !== activeTab.content) {
refreshPreview(active.content, active.previewBaseDir ?? active.filePath ?? undefined);
}
return skipped;
}, [tabs, activeTabId, activeTab.content, refreshPreview]);

// ── recent files ───────────────────────────────────────────────────────────
const loadRecentFiles = useCallback(async () => {
const { entries } = await Files.getRecent();
Expand Down Expand Up @@ -435,6 +449,7 @@ export function useEditor() {
closeTab,
closeAllTabs,
applyProjectChanges,
revertProjectChanges,
loadRecentFiles,
refreshPreview,
exportAs,
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/hooks/useProjectWork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
projectTabsAreFresh,
taskMatchesProject,
} from "@/lib/project-work-guard.mjs";
import { captureProjectWorkUndo, type ProjectWorkUndoEntry } from "@/lib/project-work-undo.mjs";

type Editor = ReturnType<typeof useEditor>;

Expand All @@ -20,6 +21,7 @@ export function useProjectWork(editor: Editor, projectRoot: string | null) {
const requestSnapshot = useRef(editor.tabs);
const projectRootRef = useRef(projectRoot);
const runGuard = useRef(createProjectWorkRunGuard());
const [undoRecord, setUndoRecord] = useState<ProjectWorkUndoEntry[] | null>(null);

useLayoutEffect(() => { currentTabs.current = editor.tabs; }, [editor.tabs]);

Expand All @@ -35,6 +37,7 @@ export function useProjectWork(editor: Editor, projectRoot: string | null) {
if (projectRootRef.current === projectRoot) return;
projectRootRef.current = projectRoot;
discard();
setUndoRecord(null);
}, [discard, projectRoot]);

const run = useCallback(async (instruction: string) => {
Expand Down Expand Up @@ -84,18 +87,29 @@ export function useProjectWork(editor: Editor, projectRoot: string | null) {
if (!projectTabsAreFresh(requestSnapshot.current, currentTabs.current)) {
throw new Error("Open documents changed while Project Work was running. Run the task again.");
}
const record = captureProjectWorkUndo(currentTabs.current, task.changes);
editor.applyProjectChanges(task.changes);
setUndoRecord(record);
await AI.discardProjectWork(task.id);
if (!isCurrentRun()) return;
taskId.current = null;
setTask(null);
setMessage("Changes were staged in editor tabs. Review and save each file.");
setMessage("Changes were staged in editor tabs. Review and save each file, or undo them.");
} catch (error) {
if (isCurrentRun()) setMessage(error instanceof Error ? error.message : String(error));
} finally {
if (isCurrentRun()) setBusy(false);
}
}, [busy, editor, task]);

return { task, busy, message, run, apply, discard };
const undo = useCallback(() => {
if (!undoRecord || busy) return;
const skipped = editor.revertProjectChanges(undoRecord);
setUndoRecord(null);
setMessage(skipped.length
? `Undid Project Work. These files were edited or saved afterwards and were left as they are:\n${skipped.join("\n")}`
: "Undid Project Work. The affected tabs are back to their previous content.");
}, [busy, editor, undoRecord]);

return { task, busy, message, run, apply, discard, undo, canUndo: !!undoRecord && !busy };
}
75 changes: 75 additions & 0 deletions frontend/src/lib/project-work-undo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Undo support for accepted Project Work results.
*
* Accepting Project Work rewrites the content of several editor tabs (and may
* open new ones). Those writes go through React state, not one Monaco model, so
* the editor's own Undo cannot step back over them. Instead we snapshot every
* tab the result is about to touch, and revert from that snapshot on request.
*
* @typedef {{ id: string, filePath: string | null, content: string, dirty: boolean }} TabLike
* @typedef {{ file_path: string, content: string }} ChangeLike
* @typedef {{
* filePath: string,
* applied: string,
* before: { content: string, dirty: boolean } | null,
* }} ProjectWorkUndoEntry
*/

/**
* Record the state of every tab a Project Work result is about to change.
* `before` is null for files the result opens as new tabs.
*
* @param {TabLike[]} tabs
* @param {ChangeLike[]} changes
* @returns {ProjectWorkUndoEntry[]}
*/
export function captureProjectWorkUndo(tabs, changes) {
return changes.map((change) => {
const tab = tabs.find((candidate) => candidate.filePath === change.file_path);
return {
filePath: change.file_path,
applied: change.content,
before: tab ? { content: tab.content, dirty: tab.dirty } : null,
};
});
}

/**
* Restore the tabs recorded by `captureProjectWorkUndo`.
*
* A tab is only reverted while it still holds exactly the AI content, so undo
* never throws away edits the user made afterwards. Such tabs, and new tabs
* that were already saved to disk, are left alone and reported in `skipped`.
* A reverted tab whose AI content was saved stays dirty, because the file on
* disk no longer matches the restored text.
*
* @template {TabLike} T
* @param {T[]} tabs
* @param {ProjectWorkUndoEntry[]} record
* @returns {{ tabs: T[], reverted: string[], skipped: string[] }}
*/
export function revertProjectWork(tabs, record) {
let next = tabs;
const reverted = [];
const skipped = [];

for (const entry of record) {
const tab = next.find((candidate) => candidate.filePath === entry.filePath);
if (!tab) continue; // Closed since apply: nothing left to revert.
if (tab.content !== entry.applied || (!entry.before && !tab.dirty)) {
skipped.push(entry.filePath);
continue;
}
const before = entry.before;
next = before
? next.map((candidate) =>
candidate === tab
? { ...candidate, content: before.content, dirty: tab.dirty ? before.dirty : true }
: candidate
)
: next.filter((candidate) => candidate !== tab);
reverted.push(entry.filePath);
}

return { tabs: next, reverted, skipped };
}
44 changes: 44 additions & 0 deletions frontend/tests/project-work-undo.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { captureProjectWorkUndo, revertProjectWork } from "../src/lib/project-work-undo.mjs";

const tab = (id, filePath, content, dirty = false) => ({ id, filePath, content, dirty });

test("undo restores edited tabs and closes tabs that Project Work opened", () => {
const before = [tab("a", "/p/a.md", "A")];
const changes = [
{ file_path: "/p/a.md", content: "A by AI" },
{ file_path: "/p/new.md", content: "New by AI" },
];
const record = captureProjectWorkUndo(before, changes);
const afterApply = [tab("a", "/p/a.md", "A by AI", true), tab("n", "/p/new.md", "New by AI", true)];

const { tabs, reverted, skipped } = revertProjectWork(afterApply, record);

assert.deepEqual(tabs, [tab("a", "/p/a.md", "A", false)]);
assert.deepEqual(reverted, ["/p/a.md", "/p/new.md"]);
assert.deepEqual(skipped, []);
});

test("undo never discards edits the user made after accepting", () => {
const record = captureProjectWorkUndo([tab("a", "/p/a.md", "A")], [{ file_path: "/p/a.md", content: "AI" }]);
const edited = [tab("a", "/p/a.md", "AI plus user typing", true)];

const { tabs, skipped } = revertProjectWork(edited, record);

assert.equal(tabs, edited);
assert.deepEqual(skipped, ["/p/a.md"]);
});

test("a saved AI result stays dirty after undo, and a saved new file is kept", () => {
const record = captureProjectWorkUndo(
[tab("a", "/p/a.md", "A")],
[{ file_path: "/p/a.md", content: "AI" }, { file_path: "/p/new.md", content: "New" }]
);
const saved = [tab("a", "/p/a.md", "AI", false), tab("n", "/p/new.md", "New", false)];

const { tabs, skipped } = revertProjectWork(saved, record);

assert.deepEqual(tabs, [tab("a", "/p/a.md", "A", true), tab("n", "/p/new.md", "New", false)]);
assert.deepEqual(skipped, ["/p/new.md"]);
});
Loading