Skip to main content

Save Points / Versioning

One-Line Summary: Complete deck state snapshots (save points) that allow users to preview and restore previous versions, with verification results preserved.


1. Overview

Save Points provide version control for slide decks within a session. Each save point captures:

  • Complete slide deck state (all slides, CSS, scripts)
  • Verification results (LLM as Judge scores) at time of snapshot
  • Chat history up to that point
  • Auto-generated description of the change

Users can preview any save point without committing, then either revert (deleting newer versions and chat messages) or cancel to return to the current state.


2. Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────────┐ │
│ │ SavePointDropdown │ │ PreviewBanner │ │ RevertConfirmModal│ │
│ └────────┬─────────┘ └────────┬─────────┘ └─────────┬─────────┘ │
│ │ │ │ │
│ └──────────────┬──────┴──────────────────────┘ │
│ │ │
│ AppLayout (versionKey, previewVersion state) │
└──────────────────────────┼───────────────────────────────────────────┘
│ API Calls

┌─────────────────────────────────────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ routes/slides.py - Version endpoints │ │
│ │ GET /versions - List all save points │ │
│ │ GET /versions/{n} - Preview specific version │ │
│ │ POST /versions/create - Create new save point │ │
│ │ POST /versions/{n}/restore - Restore and delete newer │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ services/session_manager.py - Version CRUD operations │ │
│ │ create_version(), list_versions(), get_version(), │ │
│ │ restore_version(), update_version_verification(), │ │
│ │ VERSION_LIMIT = 40 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ database/models/session.py - SlideDeckVersion model │ │
│ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

3. Database Model

# src/database/models/session.py
class SlideDeckVersion(Base):
"""Save point for slide deck versioning."""
__tablename__ = "slide_deck_versions"

id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey("user_sessions.id", ondelete="CASCADE"))
version_number = Column(Integer, nullable=False)
description = Column(String(255), nullable=False) # Auto-generated
created_at = Column(DateTime, default=datetime.utcnow)

deck_json = Column(Text, nullable=False) # Complete deck snapshot
verification_map_json = Column(Text, nullable=True) # Verification at time of snapshot
chat_history_json = Column(Text, nullable=True) # Chat messages up to this point

session = relationship("UserSession", back_populates="versions")

Table creation: Automatic via SQLAlchemy's Base.metadata.create_all(). No manual migration needed.


4. Version Limit Behavior

  • Maximum: 40 save points per session
  • Overflow: When 41st is created, the oldest (Save Point 1) is deleted
  • Numbering: Original numbers are kept (Save Points 2-41 exist after deletion, not renumbered)
  • Restore: Restoring to version N deletes all versions > N

5. API Endpoints

MethodPathPurposeHandler
GET/api/slides/versionsList all save points (newest first)routes/slides.list_versions
GET/api/slides/versions/{n}Preview specific version (no DB changes)routes/slides.preview_version
POST/api/slides/versions/createCreate new save pointroutes/slides.create_version
POST/api/slides/versions/{n}/restoreRestore version, delete newerroutes/slides.restore_version
PATCH/api/slides/versions/{n}/verificationUpdate verification on existing save pointroutes/slides.update_version_verification
POST/api/slides/versions/sync-verificationBackfill latest save point with current verificationroutes/slides.sync_version_verification

Request/Response Examples

List versions:

GET /api/slides/versions?session_id=abc123

Response:
{
"versions": [
{"version_number": 5, "description": "Edited slide 2 (HTML)", "created_at": "...", "slide_count": 4},
{"version_number": 4, "description": "Generated 4 slide(s)", "created_at": "...", "slide_count": 4}
],
"current_version": 5
}

Preview version:

GET /api/slides/versions/4?session_id=abc123

Response:
{
"version_number": 4,
"description": "Generated 4 slide(s)",
"created_at": "...",
"deck": { "title": "...", "slides": [...], "css": "...", ... },
"verification_map": { "hash1": { "score": 95, "rating": "excellent" }, ... },
"chat_history": [{ "role": "user", "content": "..." }, ...]
}

Create save point:

POST /api/slides/versions/create
{ "session_id": "abc123", "description": "Edited slide 1 (HTML)" }

Response:
{ "version_number": 6, "description": "...", "created_at": "...", "slide_count": 4 }

Restore version:

POST /api/slides/versions/4/restore
{ "session_id": "abc123" }

Response:
{
"version_number": 4,
"description": "Generated 4 slide(s)",
"deck": { ... },
"verification_map": { ... },
"chat_history": [...],
"deleted_versions": 2, // v5 and v6 were deleted
"deleted_messages": 5 // Messages after save point deleted
}

6. Frontend Components

ComponentPathResponsibility
SavePointDropdownfrontend/src/components/SavePoints/SavePointDropdown.tsxDropdown showing all versions, handles preview selection
PreviewBannerfrontend/src/components/SavePoints/PreviewBanner.tsxIndigo banner with "Revert" and "Cancel" buttons
RevertConfirmModalfrontend/src/components/SavePoints/RevertConfirmModal.tsxConfirmation dialog before deleting newer versions

State Management (AppLayout.tsx)

// Save Points / Versioning state
const [versions, setVersions] = useState<SavePointVersion[]>([]);
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
const [previewVersion, setPreviewVersion] = useState<number | null>(null);
const [previewDeck, setPreviewDeck] = useState<SlideDeck | null>(null);
const [previewMessages, setPreviewMessages] = useState<Message[] | null>(null);

// Version key for forcing re-render when switching versions
const versionKey = previewVersion
? `preview-v${previewVersion}`
: `current-v${currentVersion || 'live'}`;

// Determine which deck to display
const displayDeck = previewVersion ? previewDeck : slideDeck;
  • versionKey is passed to slide rendering components to force React to recreate elements when switching between versions
  • previewMessages is passed to ChatPanel to show historical chat during preview

7. User Flow

Preview and Restore Flow

User has 10 save points, viewing Save Point 10 (current)

Step 1: User selects Save Point 5 from dropdown
Step 2: Frontend calls GET /versions/5/preview
Step 3: App shows Save Point 5's state (PREVIEW MODE)
- Slides panel shows that version's deck
- Chat panel shows that version's messages
- PreviewBanner appears (indigo theme)
- "Revert to This Version" and "Cancel Preview" buttons
- Chat input disabled
- Slide editing disabled
- NO database changes yet

Step 4a: User clicks "Revert to This Version"
→ Confirmation modal: "Save Points 6-10 will be permanently deleted"
→ User confirms
→ POST /versions/5/restore
→ Save Point 5 becomes current, SP 6-10 deleted
→ Chat messages after SP 5 deleted
→ ChatPanel remounts to show restored chat history
→ Dropdown shows SP 1-5 only

Step 4b: User clicks "Cancel Preview"
→ Returns to Save Point 10 view
→ No changes made

Save Point Creation Triggers

Save points are created on the backend immediately after the deck is persisted. This eliminates race conditions that previously caused stale data in save points when the frontend drove creation asynchronously.

Backend triggers (in ChatService):

  • send_message / send_message_streaming -- after slide generation or chat-driven edits
  • update_slide -- after HTML panel edits

Verification backfill: Save points initially capture the deck without verification scores. After auto-verification completes, the frontend calls POST /api/slides/versions/sync-verification to retroactively update the latest save point's verification_map_json with current results. This two-phase approach ensures deck content is never lost due to timing issues while still preserving verification data.


8. Verification Persistence

Save points use a two-phase approach for verification:

  1. Phase 1 (immediate): When the backend creates a save point, it captures the current verification_map from the session. For brand-new edits, some or all slides may not yet have verification scores.
  2. Phase 2 (backfill): After auto-verification completes, the frontend calls POST /api/slides/versions/sync-verification. The backend fetches the latest save point and updates its verification_map_json with the current verification results.
  • Verification results are keyed by content hash (SHA256 of normalized HTML)
  • When previewing/restoring, verification badges reflect that version's state
  • This allows comparison of verification status across versions
  • In no-Genie mode, verification returns unable_to_verify but the sync still fires

9. Chat History Versioning

Each save point captures the chat history at the time of creation:

  • Storage: Chat messages are serialized as JSON array in chat_history_json
  • Capture: Auto-captured during save point creation (all messages up to that point)
  • Preview: ChatPanel displays historical messages via previewMessages prop (read-only)
  • Restore: Messages created after the save point are deleted from the database
  • UI Refresh: ChatPanel remounts via chatKey increment to reload restored messages
# On restore, delete messages created after the save point
if version.created_at:
deleted_messages = db.query(SessionMessage).filter(
SessionMessage.session_id == session.id,
SessionMessage.created_at > version.created_at,
).delete()

This ensures both slides and chat history stay in sync when previewing or reverting.


10. Implementation Notes

Slide ID Consistency

All slide mutations (add, delete, reorder, duplicate, replace) call ChatService._reindex_slide_ids(deck) to ensure every slide has a unique sequential slide_id (slide_0, slide_1, ...). This is a centralized static method — no inline re-indexing loops.

@staticmethod
def _reindex_slide_ids(deck: SlideDeck) -> None:
for idx, slide in enumerate(deck.slides):
slide.slide_id = f"slide_{idx}"

Cache Invalidation

On restore, the in-memory deck cache is invalidated:

def restore_version(self, session_id: str, version_number: int):
# ... restore logic ...
with self._cache_lock:
if session_id in self._deck_cache:
del self._deck_cache[session_id]

10b. Race Condition Prevention

Problem

Multiple async processes (chat streaming, manual edits, auto-verification, lock polling) can race to update deck state, causing earlier edits to be silently overwritten by stale responses.

Backend: Optimistic Locking on Chat Path

The chat paths (send_message, send_message_streaming) capture the deck version before the LLM runs and pass it as expected_version when saving:

# Before LLM call
_deck_version_before_llm = self._get_deck_version(session_id)

# After LLM call
session_manager.save_slide_deck(..., expected_version=_deck_version_before_llm)

If a manual edit bumped the version during the LLM call, VersionConflictError is raised. The chat path catches this, reloads the current deck from DB (preserving the manual edit), and skips save point creation (the manual edit already has its own).

Backend: Atomic Session Lock

acquire_session_lock uses SELECT FOR UPDATE on PostgreSQL/Lakebase to prevent two workers from acquiring the lock simultaneously (TOCTOU race). Falls back to plain query on SQLite (tests).

Frontend: Version-Gated State Updates

All setSlideDeck calls in AppLayout go through setSlideDeckGated:

const setSlideDeckGated = (newDeck, serverVersion?, force?) => {
if (!force && serverVersion != null && serverVersion < deckVersionRef.current) {
return; // Reject stale response
}
if (serverVersion != null) deckVersionRef.current = serverVersion;
setSlideDeck(newDeck);
if (versionBumped || force) loadVersionsRef.current?.(); // Refresh dropdown
};

This prevents stale getSlides responses (from lock polling, chat completion, or auto-verification) from overwriting newer edits. The deckVersionRef is reset to 0 on session switch.

Gated call sites:

  • Lock-poll interval (setSlideDeck for non-lock-holders)
  • onSlidesGenerated callback (after chat completion)
  • onSlideChange callback (after reorder/delete/duplicate/update)
  • handleRevertConfirm (force mode — user-initiated)
  • URL session restore (force mode — session switch)

Frontend: Component Remount on Version Preview

SelectionRibbon and SlidePanel both receive key={versionKey} in AppLayout, forcing React to fully destroy and recreate them when switching between version previews. This prevents stale iframe content and thumbnail corruption.


11. Cross-References