Skip to content

Session Undo/Redo

Undo/redo becomes a Session-level concern exclusively — like AI and export. Modality-level Undo/Redo intent variants are removed. Leaf sessions delegate to their single Doc<S> UndoManager. Parent sessions maintain a global undo timeline that tracks interleaved changes across parent and child sessions, delegating undo to the appropriate UndoManager. Undo grouping is configurable: time-based merge interval for typing, explicit group boundaries for AI streaming and compound operations.

Depends on Runtime Struct for the Effect return model. Interacts with Embedded Prose Reducer — embedded Prose text edits live in the parent’s LoroDoc, so Session-level undo covers them automatically. Cursor/selection restoration is deferred to a future Ephemeral Store RFC.

Today, undo/redo is fragmented:

  1. Session levelSessionIntent::Undo/Redo delegates to Doc<S>::undo/redo() via Loro’s UndoManager. Works for synced state but has no ephemeral restoration (selection, tool mode) and no awareness of child sessions.

  2. Modality levelWhiteboardIntent::Undo/Redo exists but is a no-op (Phase 2 comment). WorksheetIntent and LessonPlanIntent have no undo variants at all. Dead code.

  3. Prose — completely separate. Owns its own LoroDoc + UndoManager with cursor staging (UndoStaging via on_push/on_pop callbacks). ProseIntent::Undo/Redo handled inline in dispatch(). This becomes obsolete when Prose is embedded in the parent’s LoroDoc (the Embedded Prose RFC).

  4. Parent sessionsParentSessionIntent routes Base(SessionIntent::Undo) to the parent’s UndoManager only. If the user’s last change was in a child session (editing a whiteboard slide), undo does nothing visible — it undoes a parent-level change instead.

  5. No grouping control — Loro’s 1-second merge interval groups rapid typing, but AI-generated content (streaming over seconds) creates many separate undo steps instead of one. Compound operations (move + resize) are separate steps.

  6. No undo state on snapshotsWhiteboardSnapshot doesn’t expose can_undo/can_redo. Dart can’t enable/disable undo buttons.

We want one undo system: Session owns it, parent sessions track a global timeline across children, grouping is configurable, and all snapshots expose undo availability.

Delete Undo/Redo variants from all modality intent enums:

  • WhiteboardIntent::Undo / WhiteboardIntent::Redo — currently no-ops
  • ProseIntent::Undo / ProseIntent::Redo — replaced by Session-level undo when embedded (the Embedded Prose RFC)

ProseSession (standalone wrapper from the Embedded Prose RFC) retains its own internal undo — it wraps a full lifecycle including UndoManager. But the ProseIntent enum itself loses the variants.

SessionIntent::Undo / SessionIntent::Redo remain as the single entry point.

Leaf sessions (Session<M, SessionIntent<M::Intent>>) work as today: SessionIntent::UndoDoc::undo() → reconstitute Synced from LoroDoc → recompile → emit snapshot.

No timeline needed — one UndoManager covers all changes (modality + embedded reducers like Prose).

Parent sessions (Session<M, ParentSessionIntent<...>>) maintain an UndoTimeline that tracks the interleaving of changes across parent and child UndoManagers:

#[derive(Debug, Clone, PartialEq)]
enum UndoSource {
Parent,
Child(String), // child session id
}
struct UndoTimeline {
/// Stack of sources — consecutive duplicates collapsed.
undo_sources: Vec<UndoSource>,
/// Redo stack — populated on undo, cleared on new change.
redo_sources: Vec<UndoSource>,
}

On change: After dispatch, if the relevant UndoManager gained a new undo step, record the source. If undo_sources.last() is already the same source, don’t push (collapse consecutive edits to the same source). Clear redo_sources (linear stack — new changes discard redo history).

On undo: Peek top of undo_sources. Delegate undo() to that source’s UndoManager (parent’s Doc or child Session). If that source’s can_undo() becomes false, pop it from undo_sources (exhausted — next undo goes to previous source). Push source to redo_sources.

On redo: Mirror of undo — peek redo_sources, delegate redo(), manage exhaustion, push to undo_sources.

Example: User edits parent, then edits child 3 times, presses undo 4 times:

Actionundo_sourcesredo_sourcesWhat happens
Edit parent[Parent][]
Edit child x3[Parent, Child("s1")][]Consecutive child edits collapsed
Undo 1[Parent, Child("s1")][Child("s1")]Child undo step 3 reversed
Undo 2[Parent, Child("s1")][Child("s1")]Child undo step 2 reversed
Undo 3[Parent][Child("s1")]Child undo step 1 reversed, child exhausted → popped
Undo 4[][Child("s1"), Parent]Parent undo step reversed

Each level only knows its direct children. If a child is itself a parent session, calling undo() on it uses that child’s own timeline to determine what to undo internally. Undo cascades down.

Two mechanisms for controlling what constitutes a single undo step:

Loro’s UndoManager merges consecutive commits within the merge interval into one undo step. Default: 1 second. This handles rapid typing — each keystroke is one dispatch → one commit, but all keystrokes within 1s merge into one undo step.

For operations that span longer than the merge interval but should be a single undo step:

impl<M, I> Session<M, I> {
/// Begin an undo group. All changes until `end_undo_group()` merge into one step.
pub fn begin_undo_group(&mut self) {
self.doc.set_merge_interval(u64::MAX);
}
/// End the undo group. Restores default merge interval and commits.
pub fn end_undo_group(&mut self) {
self.doc.commit();
self.doc.set_merge_interval(DEFAULT_MERGE_INTERVAL);
}
/// Execute multiple dispatches as a single undo step.
pub fn batch(&mut self, f: impl FnOnce(&mut Self)) {
self.begin_undo_group();
f(self);
self.end_undo_group();
}
}

AI streaming: begin_undo_group() when AI generation starts (on AiIntent::Generate), end_undo_group() when generation completes (on AiFeedback::Done). Everything the AI generates — potentially over several seconds with many dispatches — becomes one undo step.

Compound operations: batch() for multi-intent operations that should undo atomically. Move + resize, paste with formatting, etc.

const DEFAULT_MERGE_INTERVAL: u64 = 1_000_000_000; // 1 second in nanoseconds
const MAX_UNDO_STEPS: usize = 100;

After Doc::undo() reconstitutes synced state, ephemeral state may be stale. Three layers of state:

LayerSynced viaRestored on undoExample
SyncedLoroDoc (persisted, CRDT)Automatically by Doc::undo()Document content, element positions
Ephemeral storeLoro awareness (synced, not persisted)Automatically (future RFC)Cursor position, selection, presence
Local ephemeralNothing (local only)Via on_undo() hookTool mode, slash menu, UI focus

Modality gains two optional hooks:

trait Modality: Reducer + Component {
// ... existing ...
/// Called after synced state is restored from undo. Fix up local ephemeral state.
fn on_undo(&mut self) {} // default no-op
/// Called after synced state is restored from redo. Fix up local ephemeral state.
fn on_redo(&mut self) {} // default no-op
}

Modalities opt in:

// Whiteboard: clear selection, reset to pointer tool
fn on_undo(&mut self) {
self.state_mut().ephemeral.selected_ids.clear();
self.state_mut().ephemeral.tool = Tool::Pointer;
}

Cursor/selection restoration via Loro’s ephemeral store is deferred to a future Ephemeral Store RFC. When that lands, on_undo() hooks will only handle truly local state (tool mode, menu visibility).

All modality snapshots gain undo availability:

pub struct WhiteboardSnapshot {
// ... existing fields ...
pub can_undo: bool,
pub can_redo: bool,
}
pub struct WorksheetSnapshot {
// ... existing fields ...
pub can_undo: bool,
pub can_redo: bool,
}
pub struct LessonPlanSnapshot {
// ... existing fields ...
pub can_undo: bool,
pub can_redo: bool,
}

For leaf sessions: can_undo = doc.can_undo(). For parent sessions: can_undo = !undo_timeline.undo_sources.is_empty() || doc.can_undo() — true if any source (parent or child) has undo steps.

No new dispatch methods needed — Undo/Redo already exist on WhiteboardSessionIntent, WorksheetSessionIntent, LessonPlanSessionIntent as session-level variants. Dart reads snapshot.can_undo / snapshot.can_redo to enable/disable buttons.

// Already works:
controller.dispatch(WhiteboardSessionIntent.undo);
controller.dispatch(WhiteboardSessionIntent.redo);
// Snapshot-driven UI:
IconButton(
onPressed: snapshot.canUndo ? () => controller.dispatch(...undo) : null,
icon: Icon(Icons.undo),
)

Interaction with the Embedded Prose RFC (Embedded Prose)

Section titled “Interaction with the Embedded Prose RFC (Embedded Prose)”

Embedded Prose text edits mutate LoroText containers inside the parent whiteboard’s LoroDoc. The Session-level UndoManager covers these mutations automatically — no special handling needed. ProseIntent::Undo/Redo are removed; text undo goes through SessionIntent::Undo.

ProseSession (standalone wrapper for FRB prose API) retains its own internal UndoManager for standalone prose editing outside of a whiteboard context.

Prose’s UndoStaging (cursor save/restore via on_push/on_pop) is retained on ProseSession only. Embedded Prose instances defer cursor restoration to the future Ephemeral Store RFC.

  1. Remove modality undo intents — delete Undo/Redo from WhiteboardIntent, ProseIntent. Remove no-op reduce handlers and keyboard shortcut handlers for undo in Prose. Verify: cargo check --workspace
  2. Snapshot fields — add can_undo: bool, can_redo: bool to WhiteboardSnapshot, WorksheetSnapshot, LessonPlanSnapshot. Wire from Doc::can_undo()/can_redo(). Verify: cargo check --workspace
  3. Undo grouping — add begin_undo_group(), end_undo_group(), batch() to Session. Wire AI streaming to use undo groups (begin on AiIntent::Generate, end on AiFeedback::Done). Verify: cargo test --workspace
  4. UndoTimeline — implement UndoTimeline struct with push(), undo(), redo(), can_undo(), can_redo(). Unit test the timeline logic independently. Verify: cargo test --package modality_session
  5. Parent session integration — add UndoTimeline to parent Session. Update ParentSessionIntent::Base(Undo/Redo) to use timeline. Detect new undo steps after child dispatch via version() comparison. Verify: cargo test --workspace
  6. Ephemeral hooks — add on_undo() / on_redo() to Modality trait with default no-op. Call after Doc::undo()/redo() in Session. Implement for Whiteboard (clear selection). Verify: cargo test --workspace
  7. FRB + Dart — regenerate bindings for snapshot changes. Update Dart UI to read canUndo/canRedo. Verify: flutter analyze

Modality-level undo — each modality handles its own undo with separate UndoManagers. Rejected because it fragments the undo experience: canvas undo, text undo, and session undo are different buttons/shortcuts. Users expect one undo that undoes the last thing they did, regardless of what it was.

Independent parent/child undo (approach A) — parent undo only affects parent’s LoroDoc, child undo only affects child’s. Simple but bad UX — if the user’s last change was in a child whiteboard, parent-level Cmd+Z does nothing visible.

Focused delegation (approach C) — parent undo always delegates to whichever child has focus. Simpler than global timeline, but wrong when the user switches focus then undoes: they’d undo the newly-focused child’s change, not the change they just made in the previous child.

Undo tree — preserve all undo branches instead of linear stack (undo 3 steps → make change → undone steps are lost in linear, preserved in tree). Rejected for now — linear undo matches user expectations from every major app (Figma, VS Code, Google Docs). Loro’s version history enables this in the future if needed.

Loro on_push/on_pop for ephemeral restoration (approach C) — wire callbacks on Session’s UndoManager to save/restore ephemeral state as undo metadata. Rejected because it couples every modality to Loro’s callback system and serialization format. The on_undo() hook is more flexible — modalities decide what to restore at undo time.

No ephemeral restoration — undo only affects synced state; ephemeral resets. Rejected because losing tool mode or selection on undo is disorienting, even if cursor restoration is deferred.

Persistent undo across page refresh — reconstruct undo stack from Loro’s OpLog after reload, or serialize the UndoManager state to local storage. Rejected because Loro’s UndoManager is session-scoped (only tracks operations observed live — imported historical ops don’t populate the stack). Reconstruction would require either a custom undo layer on top of Loro’s version DAG or UndoManager serialization APIs that don’t exist. The complexity isn’t justified — losing undo on refresh matches industry standard for collaborative editors.

Undo history is per-client and session-scoped. Created fresh when a session opens, discarded on close. Page refresh clears undo/redo stacks. This matches industry standard (Figma, Google Docs, VS Code).

Loro’s UndoManager is inherently session-scoped — it tracks operations it observes live, not historical operations. Reconstructing undo from the OpLog after reload would require either UndoManager serialization or a custom undo layer on top of Loro’s version DAG, neither of which justifies the complexity. The LoroDoc’s version history remains available for future “time travel” features if needed, but that’s separate from undo/redo.

The UndoTimeline (parent sessions) is equally ephemeral — it’s an in-memory Vec<UndoSource> that tracks interleaving during the current editing session only.

After routing ParentSessionIntent::Child { id, intent } to a child session, the parent needs to know if the child made a change (to push onto the timeline). Solution: compare child Session::version() before and after dispatch — version increments on every commit.

let v_before = child.version();
child.dispatch(intent);
if child.version() > v_before {
self.undo_timeline.push(UndoSource::Child(id));
}

No API changes needed — version: u64 already exists on Session.

Single global default (1 second) for all modalities. This is a well-tested default used by most editors. Drawing strokes, typing, and property edits all group correctly at 1s. If specific modalities need different intervals, a Modality::merge_interval() method can be added later as a backwards-compatible extension.

  • Cursor/selection restoration — deferred to a future Ephemeral Store RFC. Until then, cursor position is not restored on undo for embedded Prose or whiteboard selection handles.