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
| Method | Path | Purpose | Handler |
|---|---|---|---|
GET | /api/slides/versions | List 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/create | Create new save point | routes/slides.create_version |
POST | /api/slides/versions/{n}/restore | Restore version, delete newer | routes/slides.restore_version |
PATCH | /api/slides/versions/{n}/verification | Update verification on existing save point | routes/slides.update_version_verification |
POST | /api/slides/versions/sync-verification | Backfill latest save point with current verification | routes/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
| Component | Path | Responsibility |
|---|---|---|
SavePointDropdown | frontend/src/components/SavePoints/SavePointDropdown.tsx | Dropdown showing all versions, handles preview selection |
PreviewBanner | frontend/src/components/SavePoints/PreviewBanner.tsx | Indigo banner with "Revert" and "Cancel" buttons |
RevertConfirmModal | frontend/src/components/SavePoints/RevertConfirmModal.tsx | Confirmation 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;
versionKeyis passed to slide rendering components to force React to recreate elements when switching between versionspreviewMessagesis 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 editsupdate_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:
- Phase 1 (immediate): When the backend creates a save point, it captures the current
verification_mapfrom the session. For brand-new edits, some or all slides may not yet have verification scores. - 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 itsverification_map_jsonwith 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_verifybut 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
previewMessagesprop (read-only) - Restore: Messages created after the save point are deleted from the database
- UI Refresh: ChatPanel remounts via
chatKeyincrement 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 (
setSlideDeckfor non-lock-holders) onSlidesGeneratedcallback (after chat completion)onSlideChangecallback (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
- Backend Overview - API surface and session management
- Frontend Overview - UI components and state management
- Database Configuration - Schema details
- Slide Editing Robustness - Related deck preservation guards