Skip to content

feat(studio): add persistent undo redo#537

Merged
miguel-heygen merged 5 commits intonextfrom
feat/studio-persistent-undo-redo
Apr 30, 2026
Merged

feat(studio): add persistent undo redo#537
miguel-heygen merged 5 commits intonextfrom
feat/studio-persistent-undo-redo

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Problem

Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

What this fixes

  • Adds a persistent per-project edit-history model for file snapshots.
  • Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
  • Records source editor saves, manual DOM edits, and timeline mutations.
  • Adds toolbar undo/redo buttons with standard keyboard shortcuts: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, and Ctrl+Y.
  • Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
  • Keeps history available in memory if IndexedDB persistence fails during a session.
  • Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Root cause

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

Verification

Local checks

  • bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts -> 4 files pass, 15 tests pass
  • bun --filter @hyperframes/studio test -> 26 files pass, 289 tests pass
  • bun --filter @hyperframes/studio typecheck
  • bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts -> 0 warnings, 0 errors
  • bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts
  • git diff --check
  • bun run --filter @hyperframes/core build:hyperframes-runtime before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
  • Lefthook pre-commit -> lint, format, typecheck pass
  • Lefthook commit-msg -> commitlint pass

Browser verification

  • Started Studio locally at http://127.0.0.1:5190/#project/undo-redo-sample.
  • Used agent-browser to select a preview element in the Inspector and change #hero-card from left: 220px to left: 260px.
  • Refreshed Studio and verified Undo stayed enabled.
  • Clicked Undo and verified the project file returned to left: 220px; clicked Redo and verified the inline left: 260px returned.
  • Used agent-browser to drag the side-card timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
  • Recorded the tested undo/redo flow with agent-browser: qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm.

Notes

  • Local screenshots and recordings are kept under qa-artifacts/studio-undo-redo-2026-04-28/ and are intentionally not committed.
  • The scratch Studio project used for browser proof is local-only under packages/studio/data/projects/undo-redo-sample/ and is intentionally not committed.
  • The PR intentionally excludes the earlier PRD/TDD planning notes under docs/superpowers/; those remain local-only per request.

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff review: requesting changes.

The undo/redo model is solid, but the hook implementation can lose history entries under concurrent async saves. In packages/studio/src/hooks/usePersistentEditHistory.ts, recordEdit, undo, and redo close over the React state value from the render. If two save operations finish before React commits a render, both compute their next history from the same stale state and the later persist() wins in React state and IndexedDB.

This is realistic in Studio because source saves are debounced async work, and manual/timeline edits all go through saveProjectFilesWithHistory with file reads/writes before recordEdit. The controller helper avoids this with a mutable state variable, but the app uses the hook path.

Please move the hook to a serialized/ref-backed state transition, or otherwise guarantee that each mutation is applied to the latest history state before persistence. I would also add a regression test that fires two recordEdit calls before a rerender and verifies both undo entries are retained, except when they intentionally coalesce via the same coalesceKey.

@miguel-heygen miguel-heygen force-pushed the feat/studio-persistent-undo-redo branch from 0022e66 to 11a8745 Compare April 28, 2026 21:20
@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

@vanceingalls all was addressed btw.

@miguel-heygen miguel-heygen force-pushed the feat/studio-persistent-undo-redo branch from 11a8745 to d3b1650 Compare April 29, 2026 20:56
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Solid foundation with thoughtful separation of concerns. The pure history model, storage adapter, and serialization queue are well-designed and well-tested. There are a few correctness and scalability concerns I'd want addressed before merge, plus some smaller nits.


Architecture — the good

  • The split between pure model (editHistory.ts), storage adapter (editHistoryStorage.ts), store/controller (usePersistentEditHistory.ts), and transactional file writer (studioFileHistory.ts) is the right shape. Each piece is independently testable, and the seam where the controller injects readCurrentHashes/writeFiles keeps DOM/network IO out of the model.
  • Hash-gated apply (canApplyEditHistoryEntry) is the right defense against an external editor or process touching a file between the edit and the undo. Toast messaging communicates this back to the user.
  • The mutation queue in createPersistentEditHistoryStore (commit d3b16506) is the correct fix for racing recordEdit/undo/redo calls — the test "serializes concurrent record edits" covers this nicely.
  • Multi-file rollback in saveProjectFilesWithHistory (commit 2444aa82) handles partial-failure mid-batch and throws an AggregateError if rollback itself fails. Good defensive code.

Correctness concerns

1. Stale undoPaths/redoPaths on rapid keyboard input (real bug)

In App.tsx, handleUndo reads editHistory.undoPaths from the React snapshot at render time:

```ts
const handleUndo = useCallback(async () => {
const result = await editHistory.undo({
readCurrentHashes: () => readHistoryHashesForPaths(editHistory.undoPaths),
writeFiles: applyHistoryFiles,
});
...
}, [applyHistoryFiles, editHistory, readHistoryHashesForPaths, showToast]);
```

The store's undo() mutate runs against live state (the actual top of the stack), but readCurrentHashes is computed from the snapshot's stale undoPaths. If the user spams Cmd+Z faster than React commits between firings, the second invocation can compute hashes for the previous entry's paths, miss the live entry's paths, and fail with content-mismatch even though disk content is correct.

Fix: Move path discovery inside the store. The store already knows the live top entry — let it compute the path list it needs. Something like:

```ts
async undo(callbacks: { readHash: (path: string) => Promise; writeFiles: ... }) {
return mutate(async (state) => {
const top = state.undo[state.undo.length - 1];
const paths = top ? Object.keys(top.files) : [];
const hashes = Object.fromEntries(
await Promise.all(paths.map(async (p) => [p, await callbacks.readHash(p)] as const))
);
...
});
}
```

Then App.tsx doesn't need undoPaths/redoPaths at all (except for the disabled state and label, which it already gets via canUndo/undoLabel).

2. No rollback on partial undo/redo write

applyHistoryFiles writes files sequentially with no rollback:

```ts
const applyHistoryFiles = useCallback(async (files) => {
for (const [path, content] of Object.entries(files)) {
await writeProjectFile(path, content);
}
...
});
```

If the second of three writes fails, file 1 is at "before"/"after" target while files 2 and 3 are still at the post-edit state. The store's mutate sees the throw and leaves the entry on the undo stack — but disk is now in a hybrid state, and the next undo will hash-check against the partially written state and fail.

`saveProjectFilesWithHistory` already implements proper rollback for the forward direction. Reuse the same pattern for undo/redo — capture the current contents before writing, and revert on failure.

Today this is single-file in every wired-up call site, so the bug isn't yet reachable, but the API accepts `Record<string, string>` and the test `"reads before content, writes after content..."` uses multi-file. The contract needs to match the implementation.

3. Brief flash of previous project's history on project switch

When `projectId` changes, the effect synchronously resets `storeRef.current` and `loaded`, but does not synchronously reset `state`:

```ts
useEffect(() => {
let cancelled = false;
storeRef.current = null;
setLoaded(false);
if (!projectId) {
setState(createEmptyEditHistory());
setLoaded(true);
return;
}
loadEditHistoryState(...).then(...); // async
}, [now, projectId, storage]);
```

Between the project switch and the IndexedDB read resolving, the snapshot still reflects the previous project's stack. The undo button shows `Undo <previous-project's-label>`, and clicking is a no-op (storeRef is null), but it's confusing UX. Add `setState(createEmptyEditHistory())` synchronously at the top of the effect.

4. Cross-tab desync

Two Studio tabs open on the same project will each have their own in-memory store, both writing to the same IndexedDB key, last writer wins. There's no `BroadcastChannel` or storage event listener, so undo entries created in tab A are invisible to tab B until it reloads. Not necessarily a P0, but worth a note in the PR description so the next person hitting it knows it was a known limitation.


Scalability / production concerns

5. Storage growth — full snapshots, no size limit

Every entry stores the full `before` and `after` content of every changed file. Defaults: `maxEntries: 100`, `coalesceMs: 1500`. For a 500KB composition with active source editing, an undo stack at the cap is 100MB stored in IndexedDB per project. IndexedDB per-origin quotas are browser-dependent and effectively unbounded for most desktops, but on Chrome with low disk this can hit the eviction policy.

Options:

  • Cap by total bytes, not entry count.
  • Store diffs (unidiff or jsdiff) instead of full snapshots — the model already separates "what's stored" from "what's applied," so this is a localized change in `buildEditHistoryEntry`/the apply functions.
  • Skip the `before` copy entirely and reconstruct it from the previous entry's `after` (requires the redo direction to be careful too, but cuts storage roughly in half).

This isn't a blocker for shipping, but it's worth filing a follow-up before this is in a lot of users' hands.

6. 32-bit hash collision

`hashEditHistoryContent` is FNV-1a 32-bit. Birthday-bound: ~50% collision at ~2^16 distinct inputs. The probability of a same-project collision causing a bad undo to apply is very low in practice, but nonzero. If you want to be paranoid, swap to a 64-bit hash (xxhash, or even just `crypto.subtle.digest('SHA-1', ...)` truncated). Low priority — leaving the FNV hash is a defensible tradeoff given the toast-on-mismatch UX.


Smaller things

  • `initialState: { undo: [], redo: [] }` in `usePersistentEditHistory.test.ts` (the "serializes concurrent record edits" and "still coalesces concurrent source edits" tests) is missing the required `version: 1` and `updatedAt: number` fields on `EditHistoryState`. Either typecheck is configured loosely for tests, or these tests should be updated. Worth a quick `bun --filter @hyperframes/studio typecheck` run with the test files included to confirm.
  • Tooltips hardcode `Cmd+Z` / `Cmd+Shift+Z` regardless of platform. On Windows/Linux this should read `Ctrl+Z`. `navigator.platform` or the `event.metaKey`/`ctrlKey` distinction in the listener already covers behavior; only the label is wrong.
  • `Cmd+Y` for redo on macOS is a Windows convention. macOS uses Cmd+Shift+Z. Mapping Cmd+Y to redo on macOS is unusual but not harmful — most users won't reach for it. Optional.
  • `recordEdit` swallows storage errors (`save` has a bare `try/catch`), but `saveProjectFilesWithHistory`'s rollback test demonstrates rollback when `recordEdit` throws. Through the actual hook, `recordEdit` will never throw on storage failure. Both behaviors are individually fine, but the test "rolls back written files when history persistence fails" is exercising a path that the app integration can't reach. If the intent is that history-persistence failure should not roll back the user's edit (which is a more user-friendly choice — don't undo their work because IndexedDB hiccupped), document that and consider removing the test path or making the contract explicit.
  • No Cmd+Shift+Z in CodeMirror text fields: `shouldIgnoreHistoryShortcut` correctly skips when target is in `.cm-editor`. Good. But this also means undo via the toolbar button while focus is in CodeMirror will undo at the file level, not the editor's per-character undo. Worth a one-line comment in the description that the source editor's local undo and the project-level undo are deliberately separate stacks.
  • `useEffect` keyboard listener re-binds whenever `handleUndo` or `handleRedo` change — i.e., on every history snapshot change, since `editHistory` is in their deps. Refs would let you keep one stable listener: stash `handleUndo`/`handleRedo` in refs and dispatch through them. Nice-to-have, not a correctness issue.
  • PR description says "10 files changed, 1451 additions" — accurate for the three commits in this PR, but the Graphite stack/`next` branch base means GitHub's diff against `next` shows ~231 files. Reviewers without the Graphite context will be confused. Worth a one-line note: "diff against `next`; this stack is the last 3 commits."
  • Coalesce window is hardcoded to 1500ms and only applied to `source` kind in practice (only `handleContentChange` passes a `coalesceKey`). Consider coalescing rapid-fire DOM drags (move/resize) too — a 200-pixel drag fires many `Move layer` entries today, each with one history step. A `coalesceKey: \`move:${selection.id}\`` on the move/resize commits would compress that.

Summary

Ship-blockers in my read: #1 (stale paths) and #2 (no rollback in apply) — both are correctness bugs the test suite doesn't catch, and #1 is reachable today.

The rest are follow-up work: storage growth, cross-tab, label coalescing for drags, platform-aware tooltips, the test typecheck question. None block landing once the two correctness items are addressed.

Nice piece of work overall — the model is clean and the testing is thorough where it counts.

@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

@jrusso1020 ready for review again.

Addressed the correctness blockers from your review:

  • moved undo/redo path and hash discovery into the serialized edit-history store, so rapid queued undo/redo calls use the live top entry instead of stale React snapshot paths
  • added rollback for partial multi-file undo/redo writes before advancing history state

Also handled the small low-risk follow-ups:

  • reset the hook state synchronously on project switch
  • fixed the invalid test initial state shape
  • made undo/redo shortcut labels platform-aware
  • removed macOS Cmd+Y redo handling
  • kept one stable keyboard listener through refs
  • coalesced rapid manual move/resize history entries
  • clarified the injected-recorder rollback test wording

I left the larger product/storage items as follow-ups rather than folding them into this PR: cross-tab synchronization, byte-based history limits or diff storage, and stronger hash selection.

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff re-review (round 2)

Verdict: Both blocking correctness items from round 1 are resolved with the right fix shape, the new tests directly exercise the bugs that were identified, and the small follow-ups landed cleanly. Recommend approve.


Round 1 blockers — status

#1 Stale undoPaths/redoPaths on rapid input → ✅ Fixed correctly (dee6c5ae).

The callback API changed from readCurrentHashes/writeFiles (operating on a stale snapshot path list) to per-path readFile/writeFile. Path discovery moved inside mutate():

async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
  return mutate<ApplyResult>(async (currentState) => {
    const entry = currentState.undo[currentState.undo.length - 1];
    if (!entry) return { state: currentState, result: { ok: false, reason: "empty" } };
    const { currentFiles, currentHashes } = await readCurrentFileHashes(
      Object.keys(entry.files),
      callbacks.readFile,
    );
    ...
  });
}

Because mutate chains on the queue, the second of two concurrent undo calls correctly observes the live top entry after the first has popped. The new test "reads undo hashes from the live top entry during queued undo calls" (Promise.all([store.undo(...), store.undo(...)])) directly verifies this — it asserts readPaths === ["second.html", "first.html"], which is exactly the behavior the old snapshot-driven path discovery couldn't deliver.

#2 No rollback on partial undo/redo write → ✅ Fixed correctly (dee6c5ae).

writeFilesWithRollback captures currentFiles from the live state read, writes sequentially tracking writtenPaths, and on failure reverses through writtenPaths writing back rollbackFiles[path]. Mirrors the saveProjectFilesWithHistory pattern, including the AggregateError if rollback itself fails. The new test "rolls back files when an undo write fails partway through" asserts:

  • The error propagates.
  • Files restored to pre-undo state.
  • History entry retained on the undo stack (canRedo === false).

Both tests would fail against the round-1 implementation. Good coverage.


Small follow-ups — status

Item Status Notes
#3 Project-switch flash setState(emptyState) now runs synchronously at the top of the project-switch effect, before the projectId branch.
Test initialState shape All four usages converted to createEmptyEditHistory() (lines 103, 133, 164, 220).
Platform-aware tooltip labels New getHistoryShortcutLabel(action) reads navigator.platform and renders Cmd+Z / Ctrl+Z accordingly.
Drop Cmd+Y on macOS Listener changed from key === "y" to (event.ctrlKey && !event.metaKey && key === "y").
Stable keyboard listener handleUndoRef/handleRedoRef stash the latest closures; effect deps are [], so the listener binds once and dispatches through refs.
Coalesce manual move/resize New getDomEditCoalesceKey(selection, "move"|"resize") returns ${action}:${sourceFile}:${id|selector}, threaded through persistDomEditOperationsrecordEdit({ coalesceKey }). Consecutive drag updates on the same selection now coalesce into one history entry.
Test wording for injected-recorder rollback Renamed to "rolls back written files when the injected history recorder throws" — clearly scopes the test to the contract surface, decoupling it from the production hook's swallow-on-storage-failure behavior.

Items intentionally deferred — agreed

Cross-tab sync (#4), byte-based history limits / diff storage (#5), and stronger hash selection (#6) are reasonable follow-ups. None block landing this PR. Worth a tracking issue per item, or a single "undo/redo follow-ups" issue listing all three; happy either way.


Smaller observations (not blocking)

  • The coalesceMs default (1500ms) on move/resize means a continuous drag longer than ~1.5s still creates multiple entries (each idle gap > 1.5s flushes the coalesce). For most click-and-drag interactions this is fine; if users complain about deep undo stacks after careful long drags, consider a coalesceWindow flush-on-pointer-up signal instead of pure idle-time. Future thing.
  • ApplyResult.reason: "empty" falls through silently in App.tsx (only "content-mismatch" shows a toast). That's correct UX given the buttons are disabled={!canUndo/canRedo}, so the empty case is only reachable via keyboard with an empty stack — silent no-op is the right call.
  • editHistory snapshot still re-renders the toolbar on every onChange, but the buttons don't have effect cleanup so it's cheap. The keyboard listener is now correctly stable.

Recommendation

LGTM, ready to land. Both correctness blockers have targeted fixes with regression tests that wouldn't have passed against the round-1 code. The follow-ups list is reasonable and the deferrals are sensible.

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — both correctness blockers from round 1 are fixed with the right shape and have targeted regression tests. Deferred follow-ups (cross-tab sync, byte-based limits, stronger hash) are sensible to handle separately.

@miguel-heygen miguel-heygen merged commit 6f98232 into next Apr 30, 2026
11 checks passed
@miguel-heygen miguel-heygen deleted the feat/studio-persistent-undo-redo branch April 30, 2026 00:16
miguel-heygen added a commit that referenced this pull request Apr 30, 2026
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
miguel-heygen added a commit that referenced this pull request Apr 30, 2026
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
miguel-heygen added a commit that referenced this pull request Apr 30, 2026
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
miguel-heygen added a commit that referenced this pull request May 1, 2026
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants