Part 5 of 7|April 2026|5 min

76 Lines of Undo

ArchitectureState

Command Log

Empty — click "Next Command" to start

The same pattern that powers OpenFrame. Commands append to an immutable log. State is derived. Undo flips a flag and replays.

Three things broke at once.

Undo batches crossed agent boundaries. Browser and agent diverged. Multi-tab corrupted the log. We needed a real architecture.


The 76 lines

interface ThreadState<C> {
  threadId: string;
  commands: C[];
  undone: boolean[];
  batchIds: string[];
}

class InMemoryCommandStore<C, S> {
  private states = new Map<string, ThreadState<C>>();

  async apply(threadId: string, cmd: C, batchId: string) {
    let state = this.states.get(threadId) ?? {
      threadId,
      commands: [],
      undone: [],
      batchIds: [],
    };
    state.commands.push(cmd);
    state.undone.push(false);
    state.batchIds.push(batchId);
    this.states.set(threadId, state);
  }

  async undo(threadId: string) {
    const state = this.states.get(threadId);
    if (!state) return;
    const lastBatchId = state.batchIds[state.commands.length - 1];
    for (let i = 0; i < state.commands.length; i++) {
      if (state.batchIds[i] === lastBatchId) {
        state.undone[i] = true;
      }
    }
  }

  async redo(threadId: string) {
    const state = this.states.get(threadId);
    if (!state) return;
    const lastBatchId = state.batchIds.findLast(
      (id, i) => state.undone[i]
    );
    if (lastBatchId) {
      for (let i = 0; i < state.commands.length; i++) {
        if (state.batchIds[i] === lastBatchId) {
          state.undone[i] = false;
        }
      }
    }
  }

  async snapshot(threadId: string, def: DomainDef<C, S>) {
    const state = this.states.get(threadId);
    if (!state) return { state: def.empty, canUndo: false, canRedo: false };
    const active = state.commands.filter((_, i) => !state.undone[i]);
    let derived = def.empty;
    for (const cmd of active) {
      derived = def.apply(derived, cmd);
    }
    return { state: derived, canUndo: true, canRedo: true };
  }
}

Demo

Next Command / Undo / Redo / Reset. Watch the log.


Undo is just a stack pop.

`undoStack.pop()` → `redoStack.push()` → restore state. No SQL, no event replay, no conflict resolution. Just flip a boolean and recompute.


The reducer is pure.

190 lines, no I/O. Same reducer runs in browser and on server. Test it once, deploy everywhere.


Why not CRDTs?

If you have a central server and always-online clients, you don't need CRDTs. You need a stack.


Closing

Command store + reducer + HTTP adapter + React hook: ~500 lines total. Solved undo, redo, agent sync, and multi-tab.