plans/better-undo-redo.md
When the user clicks Undo (or Retry, which reverts before re-streaming) on the last chat turn, and performing that revert would wipe out more commits than the message's own commit — e.g. the user made manual commits via CLI/IDE, or Dyad created intermediate commits (dirty-tree checkpoints, commits from another chat on the same app) — show a confirmation AlertDialog (styled like the existing "Restore to this point?" dialog in ChatMessage.tsx) that lists the extra commits about to be reverted, with Undo anyway / Cancel actions. When the revert only covers the message's own commit (the overwhelmingly common case), behavior is unchanged: no dialog, immediate revert.
Decisions made with the user:
git revert of a single commit is a 3-way merge that can conflict with later commits, and Dyad has no conflict-resolution UI. (Possible follow-up: offer a surgical mode gated on a clean dry-run.)AlertDialog, consistent with the restore-to-message UX.src/components/chat/MessagesList.tsx → FooterComponent:
handleUndo (~line 89): finds the last message's commitHash in the versions list, targets the next-older version (versions[currentCommitIndex + 1].oid, falling back to sourceCommitHash), and dispatches { type: "RESTORE", ... } via sendPreviewMutation.handleRetry (~line 143): if the last assistant commit is still the tip (versions[0].oid === lastMessage.commitHash), reverts to the previous assistant message's commitHash (or the chat's initialCommitHash), then re-streams the last user prompt. If the tip has moved (user committed after the turn), it skips the revert entirely and just re-streams.ModifiedFilesCard (committed turns) and the standalone Undo/Retry buttons (text-only turns), all rendered in the footer.revert-version IPC → gitStageToRevert in src/ipc/utils/git_utils.ts:519) restores the target tree as a new forward commit. History isn't rewritten, but the content of every commit between HEAD and the target is silently wiped — with no detection or warning anywhere.versions (from useVersions → listVersions IPC, src/ipc/handlers/version_handlers.ts:608) is a gitLog from HEAD, newest-first, each entry carrying { oid, message, timestamp }. This is everything needed to detect and describe extra commits — no new IPC or git helper is required for detection.New file src/components/chat/revertImpact.ts (or colocate in src/shared/ if preferred for unit testing):
export function getExtraRevertedCommits({
versions, // newest-first list from useVersions
targetOid, // the revert target
ownCommitHashes, // commit(s) that are *expected* to be reverted (the message's own)
}: {...}): Version[] | null
targetIndex = versions.findIndex(v => v.oid === targetOid).targetIndex === -1 (target not in the loaded log — e.g. versions query empty/failed), return null meaning "cannot determine"; the caller proceeds exactly as today. We deliberately avoid false-positive dialogs over a safety guarantee we can't compute.versions.slice(0, targetIndex). Return those whose oid is not in ownCommitHashes.[message's commit] → extras empty → no dialog.sourceCommitHash where HEAD already equals it: targetIndex === 0 → wiped empty → no dialog.[manual2, manual1, aiCommit] → extras = the 2 manual commits → dialog.The versions query can be stale (user may have committed from the CLI since the last fetch, and the app window regaining focus doesn't guarantee a refetch has landed). On Undo/Retry click, await refreshVersions() (the refetch already exposed by useVersions) and compute both the revert target and the extras from the returned fresh data (const { data } = await refreshVersions()), not the possibly-stale closure value. This keeps detection and target-selection consistent: both come from the same snapshot.
MessagesList.tsxSplit each handler into "plan" and "perform" stages so the dialog can sit between them:
handleUndo becomes:
revertTargetVersionId (existing logic) and extraCommits via the helper, with ownCommitHashes = [currentMessage.commitHash].extraCommits?.length > 0: stash a pending action in state and open the dialog. Do not set isUndoLoading while the dialog is open.performUndo(target)).handleRetry similarly: the revert branch (both the previous-assistant-commit case and the initialCommitHash fallback) computes extras with ownCommitHashes = [lastMessage.commitHash] before reverting. On extras → dialog with a Retry-flavored label; on confirm → revert then re-stream (the whole remainder of today's handleRetry). The no-revert path (shouldRedo === true because the tip moved) never shows a dialog — it doesn't touch the codebase.FooterComponent (it already owns the handlers and loading state):const [pendingRevert, setPendingRevert] = useState<{
kind: "undo" | "retry";
targetVersionId: string;
extraCommits: Version[];
} | null>(null);
One dialog instance in the footer serves both ModifiedFilesCard and the standalone buttons, since they share the handlers.
New file src/components/chat/ExtraCommitsRevertDialog.tsx, modeled directly on the restore-to-message AlertDialog in ChatMessage.tsx (~lines 265–304):
Undo will revert additional changes (Retry: Retry will revert additional changes).max-h-48 overflow-y-auto) list of the extra commits, each showing the first line of the commit message and a relative timestamp via formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true }) — same formatting as VersionPane.tsx:260. Cap visible entries sensibly (the list is usually 1–3; scrolling covers the rest — no silent truncation).AlertDialogAction "Undo anyway" / "Retry anyway", then AlertDialogCancel "Cancel".open, onOpenChange, kind: "undo" | "retry", extraCommits: Version[], onConfirm.extra-commits-revert-dialog, confirm-revert-anyway-button, cancel-revert-button.pendingRevert and leaves loading flags untouched.Between showing the dialog and the user clicking "Undo anyway", another commit could land (agent in another chat, CLI). Defense in depth, backward compatible:
expectedHeadOid: z.string().optional() to RevertVersionParamsSchema (src/ipc/types/version.ts).revertVersion handler (src/ipc/handlers/version_handlers.ts:782 → revertCodebaseToVersion), when expectedHeadOid is provided, compare against the current HEAD before staging the revert; on mismatch throw a Conflict DyadError ("The app's history changed since you confirmed — please retry the undo."). The renderer surfaces it via the existing error toast path and refreshes versions.versions[0].oid from the same fresh snapshot used for detection. Only wire this for the dialog-confirmed path initially (the fast path already has an inherent race today; unchanged).This phase is skippable for the MVP — the refresh-on-click in step 2 already covers the realistic staleness window.
| File | Change |
|---|---|
src/components/chat/revertImpact.ts | New — pure getExtraRevertedCommits helper |
src/components/chat/ExtraCommitsRevertDialog.tsx | New — the confirmation dialog |
src/components/chat/MessagesList.tsx | Split undo/retry into plan/perform, add pendingRevert state, render dialog, refresh versions on click |
src/ipc/types/version.ts | (Phase 2) optional expectedHeadOid on revert params |
src/ipc/handlers/version_handlers.ts | (Phase 2) HEAD guard in revert path |
No DB schema, no new IPC channels, no changes to gitStageToRevert.
revertImpact.test.ts — extras computed correctly for: no extras (normal undo), N manual commits after the turn, target not found (null), target === HEAD (empty), retry with intermediate commits between two assistant turns.src/ipc/handlers/__tests__/undo.integration.test.ts (it drives the real Undo button through the real revert-version IPC):
expectedHeadOid mismatch throws Conflict and leaves the tree untouched.git revert with a clean-apply pre-check (git merge-tree dry run) — explicitly deferred per discussion, due to merge-conflict risk and no conflict UI.