Skip to content

Snapshots

Snapshots are the read-only view of session state that gets pushed to Dart. Each modality defines its own concrete snapshot type as an associated type on Modality. There is no generic SessionSnapshot<S, E> — snapshots are per-modality DTOs designed for the specific needs of each modality’s UI.

trait Modality: Reducer<...> + Component {
// ...
type Snapshot: Clone + Send + Sync + 'static;
fn snapshot(
&self,
ai: &AiState,
export: &ExportState,
version: u64,
can_undo: bool,
can_redo: bool,
) -> Self::Snapshot;
}

The snapshot() method builds a snapshot from references to the current state. It does not clone the entire state — it selectively extracts what the UI needs. This is what allows Ephemeral to omit the Clone bound (important for modalities like LessonPlan whose ephemeral state contains non-clonable children like Session<Whiteboard>).

ArgumentSourcePurpose
&selfSession.modalityDomain state: synced + ephemeral
ai: &AiStateSession.ephemeral.aiStreaming indicator, conversation ID
export: &ExportStateSession.ephemeral.exportExport bytes, readiness, errors
version: u64Session.versionMonotonic counter for change detection
can_undo: boolundo_timeline.can_undo() || doc.can_undo()Whether undo is available
can_redo: boolundo_timeline.can_redo() || doc.can_redo()Whether redo is available

AI and export state live on SessionEphemeral, not on the modality. Undo availability is computed by Session by OR-ing the UndoTimeline (parent session interleaved tracking) and Doc (leaf session LoroDoc undo). Session passes all of these as arguments so the snapshot can include them without the modality needing to know about session-level concerns.

Each modality defines a concrete struct:

ModalitySnapshot TypeKey Fields
WhiteboardWhiteboardSnapshotid, name, schema, placements, elements, selected_ids, tool settings, canvas settings, focused_prose, ai, version, can_undo, can_redo
WorksheetWorksheetSnapshotelements, placements, grid_state, ai, export, version, can_undo, can_redo
LessonPlanLessonPlanSnapshotslides, selected_slide_id, child_snapshots, ai, export, version, can_undo, can_redo

These are #[frb(non_opaque)] DTOs — FRB generates them as Dart classes with public fields. Dart renders directly from snapshot data.

Sinks are typed per-modality:

// On Session:
sinks: Vec<Box<dyn Sink<M::Snapshot>>>

The Sink trait:

pub trait Sink<T>: Send + Sync {
fn push(&self, value: T) -> bool;
}

push() returns booltrue to keep the sink, false to remove it. Session uses sinks.retain(|sink| sink.push(snapshot.clone())) so closed sinks are automatically cleaned up. Each sink receives the modality’s concrete snapshot type. The FRB API layer registers a sink that forwards snapshots to Dart via a stream. Multiple sinks can be registered (e.g., one for the main UI, one for a preview panel).

Snapshots are emitted in step 4 of the dispatch lifecycle, after all reduces have completed and the state has been recompiled and committed:

// Inside Session::dispatch(), step 5:
self.recompile();
let synced_changed = self.commit_if_changed();
self.rt.state.version += 1;
self.emit(); // <-- snapshot built and pushed here
if synced_changed { self.fire_on_change(); }

The emit() method:

  1. Computes can_undo/can_redo from undo_timeline and doc.
  2. Calls self.modality.snapshot(&ai, &export, version, can_undo, can_redo).
  3. Pushes the snapshot to all sinks via sinks.retain(|sink| sink.push(snapshot.clone())) — sinks that return false are removed.

Every dispatch() call emits a snapshot, even if nothing changed. The version counter lets consumers detect no-ops cheaply.

sequenceDiagram
participant Dispatch as Session::dispatch()
participant Modality as M::snapshot()
participant Sink1 as Sink (FRB Stream)
participant Sink2 as Sink (Preview)
participant Dart
Dispatch->>Dispatch: can_undo = timeline.can_undo() || doc.can_undo()
Dispatch->>Modality: snapshot(&ai, &export, version, can_undo, can_redo)
Modality-->>Dispatch: M::Snapshot
Dispatch->>Sink1: push(snapshot.clone()) → bool
Dispatch->>Sink2: push(snapshot.clone()) → bool
Sink1->>Dart: Stream event

The snapshot() method receives &self — an immutable reference to the modality. It reads from synced and ephemeral state to build the DTO:

impl Modality for Whiteboard {
fn snapshot(
&self, ai: &AiState, _export: &ExportState,
version: u64, can_undo: bool, can_redo: bool,
) -> WhiteboardSnapshot {
WhiteboardSnapshot {
id: self.rt.state.synced.id.clone(),
name: self.rt.state.synced.name.clone(),
description: self.rt.state.synced.description.clone(),
schema: self.rt.state.synced.schema.clone(),
placements: /* convert placements */,
elements: self.rt.state.ephemeral.output.clone(),
selected_ids: self.rt.state.ephemeral.selected_ids.clone(),
current_tool: self.rt.state.ephemeral.current_tool,
// ... tool_color, tool_thickness, tool_shape_type, tool_font_family,
// ... canvas_zoom, canvas_pan_x, canvas_pan_y, canvas_width, canvas_height
focused_prose: /* from focused_text_id + prose_editors */,
ai: ai.clone(),
version,
can_undo,
can_redo,
}
}
}

Only the fields needed by the UI are cloned. Large state that the UI does not need (e.g., prose editor internals, internal caches) is excluded. The can_undo/can_redo flags are passed through from Session’s undo computation.