How We Built Undo/Redo in an AI-Powered Code Editor
By Rishav
12th Oct 2026
Last updated: 12th Oct 2026
Every code editor has a Cmd+Z. Press it and the last edit disappears, press it again and the one before that disappears, and so on down a stack of operations. VS Code works this way, Figma works this way, Google Docs works this way. It is one of the most studied interactions in software.
Then we built RapidNative — an AI mobile app builder where a single user action can rewrite twelve files, add three dependencies, migrate a database schema, and re-flow the whole app in forty seconds of streamed tokens — and the stack-based undo everyone takes for granted stopped making sense.
This is a technical walkthrough of what we built instead. If you are designing undo/redo for any agent-driven editor — code, design, docs — the architecture choice we made here is likely the one you need too.
Why Cmd+Z breaks the moment an AI starts typing
A text editor's undo stack has one implicit contract: the user made the change, and the user can reason about reversing it. You typed a letter, Cmd+Z un-types that letter. You pasted a block, Cmd+Z un-pastes it. The granularity of "an edit" matches the granularity of your intent.
That contract shatters the moment the editor is an LLM. In the RapidNative editor, a single prompt — "Add a profile screen with avatar upload and link it from the tab bar" — can produce, in one streaming response from the model:
- A new file
app/(tabs)/profile.tsx(write) - An edit to
app/(tabs)/_layout.tsxto register the new tab (edit) - A new component
components/AvatarPicker.tsx(write) - A new dependency added to
package.json(edit) - A new storage bucket migration in
supabase/migrations/(write) - A regenerated
mobile/src/db/types.ts(write)
Now ask: what does Cmd+Z mean here?
If it pops one edit off the stack, you have deleted the tab-bar wiring but kept the orphan profile screen. The app is in an inconsistent state the user never asked for. If it pops all six edits in a batch, you have invented a new kind of operation ("the whole generation") that does not exist in the data model — the editor only sees individual file writes streaming by.
The deeper problem is that the user's mental unit of work is not an edit, it is a request. They said "add a profile screen"; everything the model did in service of that request is one indivisible thing to them. Any undo scheme that cannot name "the thing the AI just did" and reverse it atomically is going to feel broken.
The two architectures we considered
There are, broadly, two ways to build undo. We put both on the table.
Option A: a traditional edit stack. Record every file write, every create, every delete as a reversible operation. Push to an undoStack, pop on undo, push the inverse to a redoStack. This is the classic implementation and there are mature libraries for it — redux-undo wraps a reducer and gives you time travel almost for free.
Option B: event sourcing with replay. Treat the project's current state as a derivation from a known base plus an ordered log of events. To undo, pop the last event from the log and rebuild from scratch. This is how databases do write-ahead logs. It is how Git works at a conceptual level — a commit is not a snapshot, it is a diff you can replay.
For a traditional code editor, Option A wins on raw performance. You do not want to rebuild a 10,000-file project every time someone hits Cmd+Z.
For an AI editor, Option B wins on nearly every other axis. Here is why we chose it.
The log already exists
In an AI editor, the authoritative record of why the project looks the way it does is the conversation. Every file in the project traces back to a user prompt and the model's response. The messages table is the log, and we were going to have it whether or not it also powered undo. Making it the source of truth for history meant one data model, not two.
Granularity matches the user's intent
A message is exactly the unit the user cares about. "Undo" in RapidNative does not mean "un-type a character" — it means "reverse that last thing I asked the AI to do." Replaying N-1 messages gets you exactly there.
The AI already speaks this language
Our coding agent emits structured tool calls: begin_write_file, write_file_content, edit_file, move_file, platform_action. Each one is a deterministic mutation we can describe, store, and replay. We did not need to invent a reversible-operations API; the agent was already producing one.
Multi-file consistency is free
If "undo one message" means "replay base + all messages except the last one," there is no state in which the profile screen exists but the tab-bar wiring does not. The replay is atomic by construction.
How message replay actually works
Here is the shape of revertFiles in src/modules/editor/store/thunks/editorThunks.ts. I'll skip the boilerplate and show the moves that matter.
Step 1: Pick the base state. For a v3+ project (the current coding-agent stack), the base is one of two things:
- Normal project: the template files for the project's
componentBase(e.g. the stockfullstack-supabasescaffold). - Zip-imported project: the files from the original upload, fetched from
/api/user/projects/{projectId}/base-snapshot.
The zip-import branch exists because importing an external codebase is itself a user action we do not want undone — the base has to be the imported snapshot, not the stock template. If that fetch fails, revert aborts cleanly without recording a half-rebuild.
Step 2: Load the message log. All messages for the project are fetched in order from Postgres. Each row has a type (user | assistant | system), a content payload, and a status.
Step 3: Replay in order. For each assistant message, apply its file mutations onto the base. For v3 messages, the mutations are stored as a serialised UIMessagePart[] inside a {"__v3":true, text, parts} content envelope. For each tool part we switch on toolName:
begin_write_file→ remember the path.write_file_content→ upsert the file content.edit_file→ find the file, replaceold_stringwithnew_string, bail with a warning if the string isn't present.move_file→ rename, overwriting the destination if it exists (same semantics as the live tool).platform_actionwithdelete_file→ remove the file.
File-producing tools like bash or db_* get their outputs replayed too, including explicit deletions — otherwise a rm or mv the agent performed gets silently resurrected on undo, and you end up with a duplicate route the model thought it had removed.
Step 4: Commit. The rebuilt file set is written to the editor's virtual file system in one shot. The bundler picks up the change, HMR fires, the preview iframe updates. No manual reload.
Step 5: Skip the garbage. Any assistant message with status failed, stopped, or aborted is skipped entirely. These messages hold partial content — a half-streamed write_file_content that was never completed — and replaying them corrupts files that are actually fine.
That last point looks like trivia but it is one of the invariants that lets the whole architecture stand. Event sourcing only works if events are complete and durable. Our rule is: a message is either fully committed and replayable, or it is noise. No in-between.
The one file that does NOT get overwritten: package.json
Every file in the replay is clobbered by the latest write that touches it. Classic last-write-wins. There is exactly one exception: package.json.
Imagine the user runs three generations in a row:
- "Add a chart" — model writes a
package.jsonthat addsvictory-native. - "Add a date picker" — model writes a
package.jsonthat adds@react-native-community/datetimepicker. - "Change the font" — model writes a
package.jsonwith no new deps.
Naive last-write-wins means the final package.json from message 3 overwrites the previous two. The chart and date-picker dependencies vanish. The app breaks. Nothing on screen tells you why.
The fix is mergePackageJsonAddOnly. Instead of overwriting, each package.json write is textually merged into the running state — new dependency entries accumulate, existing ones are preserved. The same rule applies on streaming writes and on replay. If you only applied it in one place, revert would quietly produce a package.json that differs from the one streaming produced for the same inputs, and determinism dies.
This is the kind of invariant that reads as a one-liner in the code and takes a week to appreciate. The whole value of event sourcing is that replay(events) === current_state. The moment any operation is non-deterministic, that equation breaks and the undo graph starts drifting from reality.
The constraints that bit us
Three things made the architecture more interesting than it looked on paper.
1. Deletions have to replay too
The first version of the replay only knew about writes and edits. If the agent used rm in a bash step, the deletion was absent from the recorded tool parts (early on) and the file popped back on undo. That mismatch produced duplicate routes — expo-router's Unmatched Route screen in the preview — and took longer to diagnose than it should have. Explicit output.deleted[] arrays on file-producing tools now propagate the removals through replay. If your event log cannot represent "I removed this," you have an append-only log of lies.
2. Templates evolve underneath you
We hit this during the Expo 57 upgrade. A background migration script was walking every project and bumping its files to the new Expo version. The problem: a user who ran revert on a migrated project would replay all their messages onto the new template base and get a correct new-Expo project. But the messages themselves were written against the old template, so string edits in the log would fail to apply, and partial state would get through.
The pragmatic answer, in src/shared/constants/rollback.ts:
export const ROLLBACK_DISABLED = true;
A kill switch. While the migration is running, the undo button is hidden, the agent is told via its system prompt to undo its own last change by making a new corrective edit, and any rollback request that still slips through is dropped on the client. Once the migration finishes, the flag flips back.
This is not elegant. It is honest. The event-sourcing idea works perfectly on a fixed base — and migrations are, by definition, a moving base. If you build this architecture, you will one day migrate your templates, and you need a kill switch for the week it takes.
3. "Restore" is a recursive replay
There is a special message type whose content is literally the string restore <messageId>. When the replay engine encounters it, instead of applying mutations it recursively replays the referenced message onto the current state. This is how a user can jump back to any earlier point without having to undo one message at a time.
The recursion is bounded by the message log (you can only restore to a message that exists), and failed/stopped messages are refused as restore targets for the same reason they are skipped during normal replay — their content is incomplete.
What we gave up
Message-replay undo is the right architecture for an AI editor, but it is not free. Three things it does not give you.
No per-keystroke undo. The granularity is a message, not a file edit, not a character. If you point-and-edit a button label from "Submit" to "Send," that is a model generation like any other and the whole generation is the undo unit. For a text editor this would be unbearable; for an AI editor it matches intent.
No Cmd+Z. We deliberately did not bind the keyboard shortcut. In early prototypes we did, and users would mash it expecting character-level undo on a message they were still typing — then wonder why their last app generation disappeared. The button lives under each AI message in the chat thread, where its scope is unambiguous.
Replay time scales with history. For a project with 150 messages, revert does more work than popping a stack. In practice this hits the editor's VFS, not the user — the bundler debounces and HMR coalesces — but it means we can't pretend replay is free. The win is that we never have to pay the cost of maintaining an inverse operation for every tool call, which is where edit-stack systems get expensive to extend.
What this means for AI editors in general
If you are building anything agent-driven — a code editor, a design tool, a document generator, a canvas — the undo question comes for you eventually. Here is the short version of what we would tell our past selves.
Treat the conversation as the log. You already have it, your users already understand it, and its granularity is the one they care about. Do not invent a second history data model on the side.
Make every tool call deterministically replayable. Write-oriented tools are easy. Edit tools need their old_string/new_string payload stored exactly as applied, not reconstructed. Deletions need explicit outputs. Shell/db tools need to report the files they touched. If any of those are missing, your log is lossy.
Reserve one escape hatch for non-idempotent operations. package.json, lockfiles, generated types — anything where two writes should compose, not overwrite — gets its own merge rule. The rule has to apply identically on live streaming and on replay or your undo will diverge.
Build the kill switch on day one. Templates will move, schemas will migrate, the base state will shift under you. A boolean flag that disables undo gracefully is a 10-line commit when you need it and a weekend when you don't.
The design we landed on is not a universal answer. For a conventional IDE it would be overkill; for a drawing tool it would be the wrong shape. But for an AI mobile app builder — where a single prompt means a dozen coordinated file changes — treating undo as "replay all but the last message" is the architecture that actually matches what users are doing.
If you want to see this in practice, you can start building a React Native app from a prompt in a few minutes. The undo button under each AI message is doing exactly what this post describes — rebuilding the project from a known template, replaying every message except the one you reverted, writing the result into the VFS, and letting HMR do the rest.
For more on the editor internals, read our deep dives on how we built RapidNative's editor, the virtual file system behind multi-file AI code generation, and how user edits improve RapidNative's AI feedback loop. If undo lives in a different place in your stack, those posts show where it plugs in.
Further reading on event sourcing and replay-based state models: Martin Fowler's Event Sourcing remains the canonical write-up, and the Redux Toolkit docs on reducer composition are where the implementation details of our editor state live. If you prefer the stack approach, redux-undo is the mature library in that lineage.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.