diff --git a/backend/packages/adapter/src/index.ts b/backend/packages/adapter/src/index.ts index 22023d9..5f6a51c 100644 --- a/backend/packages/adapter/src/index.ts +++ b/backend/packages/adapter/src/index.ts @@ -52,6 +52,10 @@ startIpcListener(async (msg: FrontendMessage) => { adapter.handleRoomActionResponse(msg); break; + case 'restore': + await adapter.handleRestore(msg); + break; + default: emit({ type: 'error', diff --git a/backend/packages/adapter/src/sdk-adapter.ts b/backend/packages/adapter/src/sdk-adapter.ts index 923c396..4f92daf 100644 --- a/backend/packages/adapter/src/sdk-adapter.ts +++ b/backend/packages/adapter/src/sdk-adapter.ts @@ -20,6 +20,7 @@ import { type UserMessage, type RoomQueryResponseMessage, type RoomActionResponseMessage, + type RestoreMessage, } from '@tiny-workshop/ipc-types'; import { createLogger } from '@tiny-workshop/logger'; import { emit } from './ipc.js'; @@ -154,10 +155,15 @@ export class SdkAdapter { // Multi-turn session state private inputGenerator: InputGenerator | null = null; private queryTask: Promise | null = null; + private currentQuery: ReturnType | null = null; // Track accumulated response text for assistant_message_complete private currentResponseText: string = ''; + // Checkpoint restoration options (set via init, cleared after use) + private forkSession: boolean = false; + private resumeAtMessage: string | undefined = undefined; + constructor() { // No OAuth setup needed - just API key authentication } @@ -185,6 +191,16 @@ export class SdkAdapter { log.info('Session resume requested', { sessionId: this.sessionId }); } + // Store checkpoint restoration options + this.forkSession = msg.fork_session ?? false; + this.resumeAtMessage = msg.resume_at_message; + if (this.forkSession || this.resumeAtMessage) { + log.info('Checkpoint restoration options set', { + forkSession: this.forkSession, + resumeAtMessage: this.resumeAtMessage, + }); + } + // Check for API key if (!msg.api_key) { emit({ @@ -278,6 +294,12 @@ export class SdkAdapter { this.inputGenerator = createInputGenerator(); this.abortController = new AbortController(); + // Capture and clear checkpoint restoration options (one-time use) + const forkSession = this.forkSession; + const resumeAtMessage = this.resumeAtMessage; + this.forkSession = false; + this.resumeAtMessage = undefined; + try { // Dynamically import the SDK AFTER env vars are set log.debug('Loading SDK', { baseUrl: ZAI_BASE_URL }); @@ -286,29 +308,51 @@ export class SdkAdapter { // Create room tools MCP server for this query const roomToolsServer = createRoomToolsServer(); + // Build SDK options + const sdkOptions: Parameters[0]['options'] = { + cwd: this.workingDir, + resume: this.sessionId ?? undefined, + permissionMode: 'bypassPermissions', + abortController: this.abortController, + pathToClaudeCodeExecutable: resolveClaudeExecutable(), + // Enable file checkpointing for restore functionality + enableFileCheckpointing: true, + // Request user message UUIDs in the stream (required for checkpoint IDs) + extraArgs: { 'replay-user-messages': null }, + env: { + ...process.env, + ANTHROPIC_BASE_URL: ZAI_BASE_URL, + ANTHROPIC_AUTH_TOKEN: this.apiKey, + // Enable SDK file checkpointing feature + CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', + }, + mcpServers: { + 'tiny-workshop-room': roomToolsServer, + }, + stderr: (data: string) => { + log.error('SDK subprocess stderr', { data: data.trim() }); + }, + }; + + // Add fork/resume options if restoring to a checkpoint + if (forkSession) { + (sdkOptions as Record).forkSession = true; + log.info('Session will fork (creating new branch)'); + } + if (resumeAtMessage) { + (sdkOptions as Record).resumeSessionAt = resumeAtMessage; + log.info('Session will resume at specific message', { messageId: resumeAtMessage }); + } + // Start a long-running query with the input generator const queryResult = query({ prompt: this.inputGenerator.generator, - options: { - cwd: this.workingDir, - resume: this.sessionId ?? undefined, - permissionMode: 'bypassPermissions', - abortController: this.abortController, - pathToClaudeCodeExecutable: resolveClaudeExecutable(), - env: { - ...process.env, - ANTHROPIC_BASE_URL: ZAI_BASE_URL, - ANTHROPIC_AUTH_TOKEN: this.apiKey, - }, - mcpServers: { - 'tiny-workshop-room': roomToolsServer, - }, - stderr: (data: string) => { - log.error('SDK subprocess stderr', { data: data.trim() }); - }, - }, + options: sdkOptions, }); + // Store query reference for rewindFiles access + this.currentQuery = queryResult; + // Process the query output in the background this.queryTask = this.processQueryOutput(queryResult); } catch (error) { @@ -318,6 +362,7 @@ export class SdkAdapter { await this.queryTask?.catch(() => {}); // Wait for any pending operations this.inputGenerator = null; this.queryTask = null; + this.currentQuery = null; this.abortController = null; throw error; } @@ -368,6 +413,7 @@ export class SdkAdapter { this.inputGenerator?.complete(); this.inputGenerator = null; this.queryTask = null; + this.currentQuery = null; this.abortController = null; // NOTE: We intentionally do NOT reset sessionId here! // The sessionId is preserved so the next message can use resume: sessionId @@ -394,6 +440,64 @@ export class SdkAdapter { process.exit(0); } + /** + * Handle restore request - rewind files and fork session to a previous checkpoint + */ + async handleRestore(msg: RestoreMessage): Promise { + log.info('Restore requested', { + sessionId: msg.session_id, + checkpointId: msg.checkpoint_id, + }); + + // 1. If there's an active session, close it + if (this.inputGenerator || this.abortController) { + log.debug('Closing active session before restore'); + this.inputGenerator?.complete(); + this.abortController?.abort(); + // Wait for cleanup + await this.queryTask?.catch(() => {}); + this.inputGenerator = null; + this.queryTask = null; + this.currentQuery = null; + this.abortController = null; + } + + // 2. Set up for next session with forking and resume-at options + this.sessionId = msg.session_id; + this.forkSession = true; + this.resumeAtMessage = msg.checkpoint_id; + + // 3. Start a new session which will fork and resume at the checkpoint + // Note: The startSession method will use forkSession and resumeAtMessage + // and then clear them after use + try { + await this.startSession(); + + // 4. Rewind files to the checkpoint + // We need to push an empty message to trigger the stream, then call rewindFiles + if (this.currentQuery && typeof (this.currentQuery as any).rewindFiles === 'function') { + log.debug('Rewinding files to checkpoint', { checkpointId: msg.checkpoint_id }); + await (this.currentQuery as any).rewindFiles(msg.checkpoint_id); + log.info('Files rewound successfully'); + } else { + log.warn('rewindFiles not available on query - SDK version may not support this'); + } + + emit({ + type: 'session_started', + session_id: this.sessionId ?? '', + }); + } catch (error) { + log.error('Failed to restore to checkpoint', { error }); + emit({ + type: 'error', + code: ErrorCodes.SDK_ERROR, + message: `Failed to restore to checkpoint: ${error}`, + recoverable: true, + }); + } + } + /** * Handle room query response from frontend */ @@ -479,10 +583,21 @@ export class SdkAdapter { } case 'user': { - // User messages contain tool results + // User messages contain tool results and checkpoint UUIDs const message = msg.message as Record | undefined; const content = message?.content as Array> | undefined; + // Check for checkpoint UUID (from replay-user-messages option) + const uuid = msg.uuid as string | undefined; + if (uuid) { + log.debug('Checkpoint UUID received', { uuid }); + emit({ + type: 'checkpoint_created', + checkpoint_id: uuid, + timestamp: new Date().toISOString(), + }); + } + if (content) { for (const block of content) { if (block.type === 'tool_result') { diff --git a/backend/packages/ipc-types/src/index.ts b/backend/packages/ipc-types/src/index.ts index e3a27aa..9ffb896 100644 --- a/backend/packages/ipc-types/src/index.ts +++ b/backend/packages/ipc-types/src/index.ts @@ -92,6 +92,10 @@ export interface InitMessage { api_key?: string; /** SDK session ID to resume (for conversation continuity across restarts) */ resume_session_id?: string; + /** If true, fork the session instead of continuing it (creates new session ID) */ + fork_session?: boolean; + /** Resume at specific message UUID instead of latest (for checkpoint restoration) */ + resume_at_message?: string; } /** User sends a chat message */ @@ -126,6 +130,15 @@ export interface RoomActionResponseMessage { message: string; } +/** Restore to a previous checkpoint (fork session + rewind files) */ +export interface RestoreMessage { + type: 'restore'; + /** SDK session ID to restore from */ + session_id: string; + /** Checkpoint UUID to restore to (from checkpoint_created message) */ + checkpoint_id: string; +} + /** All possible frontend → backend messages */ export type FrontendMessage = | InitMessage @@ -133,7 +146,8 @@ export type FrontendMessage = | CancelMessage | ShutdownMessage | RoomQueryResponseMessage - | RoomActionResponseMessage; + | RoomActionResponseMessage + | RestoreMessage; // ============================================================================= // Backend → Frontend Messages @@ -221,6 +235,15 @@ export interface CompactionOccurredMessage { trigger: 'manual' | 'auto'; } +/** Checkpoint was created (for restore functionality) */ +export interface CheckpointCreatedMessage { + type: 'checkpoint_created'; + /** UUID from SDK user message - used as restore point identifier */ + checkpoint_id: string; + /** ISO 8601 timestamp when checkpoint was created */ + timestamp: string; +} + /** All possible backend → frontend messages */ export type BackendMessage = | SessionStartedMessage @@ -233,7 +256,8 @@ export type BackendMessage = | AuthStatusMessage | RoomActionMessage | RoomQueryMessage - | CompactionOccurredMessage; + | CompactionOccurredMessage + | CheckpointCreatedMessage; // ============================================================================= // Error Codes @@ -268,7 +292,8 @@ export function isFrontendMessage(msg: unknown): msg is FrontendMessage { m.type === 'cancel' || m.type === 'shutdown' || m.type === 'room_query_response' || - m.type === 'room_action_response' + m.type === 'room_action_response' || + m.type === 'restore' ); } @@ -287,6 +312,7 @@ export function isBackendMessage(msg: unknown): msg is BackendMessage { m.type === 'auth_status' || m.type === 'room_action' || m.type === 'room_query' || - m.type === 'compaction_occurred' + m.type === 'compaction_occurred' || + m.type === 'checkpoint_created' ); } diff --git a/docs/design/ISSUES.md b/docs/design/ISSUES.md index 52096a1..afa49d3 100644 --- a/docs/design/ISSUES.md +++ b/docs/design/ISSUES.md @@ -11,30 +11,9 @@ This document catalogs remaining design gaps and open questions that need to be Issues that should be resolved early in implementation to avoid rework. -### 1. Checkpoint Restoration Not Specified +### ~~1. Checkpoint Restoration Not Specified~~ -**Severity**: 🟡 **High** (Deferred to Phase 3) -**Component**: Frontend Architecture, Persistence System -**Status**: Not blocking MVP - -**Problem**: -The design mentions double-Esc to "restore past state" (checkpoint restoration), but doesn't define: -- When are checkpoints created? -- What state is included? (Conversation? Room? Files?) -- How far back can we restore? -- How is this different from session resumption? - -**Impact**: -- Cannot implement full double-Esc shortcut functionality -- May conflict with SDK session management - -**Recommendation**: **Defer to Phase 3** (not MVP). For MVP, double-Esc just cancels generation. Checkpoint restoration requires more SDK research to understand session forking capabilities. - -**Action Items (Phase 3)**: -1. Research SDK session forking/branching capabilities -2. Design checkpoint creation strategy (automatic vs manual) -3. Determine what state to snapshot (conversation, room, both) -4. Implement restore mechanism +**Status**: ✅ **RESOLVED** (2026-01-15) → Moved to Resolved Issues section --- @@ -145,14 +124,13 @@ Persistence via `game/src/persistence/settings.rs` with atomic writes. | Priority | Total | Resolved | Remaining | |----------|-------|----------|-----------| | Critical | 1 | 1 | 0 | -| High | 4 | 2 | 2 (1 deferred) | +| High | 4 | 3 | 1 (deferred) | | Medium | 5 | 4 | 1 (deferred) | | Low | 3 | 2 | 1 | ### Active Issues Requiring Action **Deferred to Post-MVP**: -- Issue #1: Checkpoint restoration (Phase 3) - Issue #5: Protocol semantic versioning (v2.0) - Issue #6: Animation event callbacks (Phase 4) @@ -174,6 +152,7 @@ The following issues have been resolved and incorporated into design documents: 10. ✅ **Image Thumbnails** (Issue #7) → Texture rendering implemented using `MaterialResource::new_embedded()` with LRU image cache. Full-size images rendered directly on cubes, making thumbnails unnecessary. (2026-01-10) 11. ✅ **Session Log SDK Integration** → Session resume implemented in `game/src/lib.rs:handle_session_picker_result()` with SDK session ID extraction via `persistence::extract_sdk_session_id()`, history display via `display_session_history()`, and backend resume via `query({ resume: sessionId })` in `backend/packages/adapter/src/sdk-adapter.ts`. Tested and verified: historical messages display on resume AND SDK maintains conversation context across restarts. (2026-01-11) 12. ✅ **Settings Dialog Implementation** (Issue #8) → Settings dialog implemented in `game/src/settings_dialog.rs` with tabbed interface (Graphics, Audio, Auth, Advanced). Settings apply to Fyrox engine at startup via `on_scene_loaded()` and when saved from dialog via `apply_graphics_settings()` and `apply_audio_settings()`. Quality presets convert to Fyrox `QualitySettings` via `game/src/settings_applier.rs`. (2026-01-14) +13. ✅ **Checkpoint Restoration** (Issue #1) → Full checkpoint restoration system implemented with SDK file checkpointing integration. Checkpoints automatically created on each assistant response via `CheckpointCreatedMessage` IPC with room state snapshots stored in session log. Double-Esc opens `CheckpointPicker` UI (`game/src/checkpoint_picker.rs`) displaying checkpoints newest-first. Restore triggers SDK session forking via `RestoreMessage` IPC with `fork_session=true`, rewinds conversation history, and restores room state from snapshot. Debug server endpoints (`GET /checkpoint-picker`, `POST /checkpoint-picker/action`) enable automated testing. Tested and verified: session forking creates new session_id, chat UI clears and re-displays messages up to checkpoint, room state (notes/images) restores from JSON snapshot. (2026-01-15) --- @@ -192,3 +171,4 @@ The following issues have been resolved and incorporated into design documents: | 2026-01-11 | 2.7 | Added text rendering to room system (fonts.rs, text_renderer.rs) | | 2026-01-11 | 2.8 | Resolved session log SDK integration (session resume with context preservation) | | 2026-01-14 | 2.9 | Resolved Issue #8: Settings dialog with engine application | +| 2026-01-15 | 3.0 | Resolved Issue #1: Checkpoint restoration with SDK file checkpointing and session forking | diff --git a/tiny-workshop/game/src/backend.rs b/tiny-workshop/game/src/backend.rs index a2cafc0..3ff9028 100644 --- a/tiny-workshop/game/src/backend.rs +++ b/tiny-workshop/game/src/backend.rs @@ -44,6 +44,22 @@ impl BackendHandle { working_dir, api_key, resume_session_id, + fork_session: None, + resume_at_message: None, + }); + } + + /// Send restore message to restore to a previous checkpoint + /// + /// This will fork the session and rewind files to the checkpoint state. + /// + /// # Arguments + /// - `session_id` - SDK session ID to restore from + /// - `checkpoint_id` - Checkpoint UUID to restore to + pub fn send_restore(&self, session_id: String, checkpoint_id: String) { + let _ = self.send(FrontendMessage::Restore { + session_id, + checkpoint_id, }); } @@ -212,11 +228,35 @@ mod tests { working_dir, api_key, resume_session_id, + fork_session, + resume_at_message, } => { assert_eq!(version, PROTOCOL_VERSION); assert_eq!(working_dir, "/test"); assert_eq!(api_key, Some("zai-key-123".to_string())); assert_eq!(resume_session_id, None); + assert_eq!(fork_session, None); + assert_eq!(resume_at_message, None); + } + _ => panic!("Wrong message type"), + } + } + + #[tokio::test] + async fn test_send_restore() { + let (tx, mut rx) = mpsc::channel::(10); + let handle = BackendHandle { tx }; + + handle.send_restore("session-abc".to_string(), "checkpoint-xyz".to_string()); + + let msg = rx.recv().await.unwrap(); + match msg { + FrontendMessage::Restore { + session_id, + checkpoint_id, + } => { + assert_eq!(session_id, "session-abc"); + assert_eq!(checkpoint_id, "checkpoint-xyz"); } _ => panic!("Wrong message type"), } diff --git a/tiny-workshop/game/src/chat_ui.rs b/tiny-workshop/game/src/chat_ui.rs index 2e44e1f..cc58e47 100644 --- a/tiny-workshop/game/src/chat_ui.rs +++ b/tiny-workshop/game/src/chat_ui.rs @@ -588,6 +588,29 @@ impl ChatUI { visible, )); } + + /// Clear all messages from the conversation. + /// + /// Used when restoring to a checkpoint to clear the conversation history + /// before re-displaying messages from that point. + pub fn clear_messages(&mut self, ui: &mut UserInterface) { + // Get all children of the conversation stack + if let Some(node) = ui.try_get_node(self.conversation_stack) { + let children: Vec> = node.children().to_vec(); + + // Remove each child widget + for child in children { + ui.send_message(WidgetMessage::remove( + child, + MessageDirection::ToWidget, + )); + } + } + + // Clear streaming state as well + self.streaming_text_widget = None; + self.streaming_content.clear(); + } } // ============================================================================= diff --git a/tiny-workshop/game/src/checkpoint_picker.rs b/tiny-workshop/game/src/checkpoint_picker.rs new file mode 100644 index 0000000..9249356 --- /dev/null +++ b/tiny-workshop/game/src/checkpoint_picker.rs @@ -0,0 +1,365 @@ +//! Checkpoint Picker UI Module +//! +//! Provides a modal dialog for selecting a checkpoint to restore to. +//! Triggered by double-Esc keyboard shortcut. + +use fyrox::core::color::Color; +use fyrox::{ + core::pool::Handle, + gui::{ + brush::Brush, + button::{ButtonBuilder, ButtonMessage}, + formatted_text::WrapMode, + grid::{Column, GridBuilder, Row}, + message::{MessageDirection, UiMessage}, + scroll_viewer::ScrollViewerBuilder, + stack_panel::StackPanelBuilder, + text::TextBuilder, + widget::{WidgetBuilder, WidgetMessage}, + window::{WindowBuilder, WindowTitle}, + BuildContext, Thickness, UiNode, UserInterface, + }, +}; + +use crate::persistence::Checkpoint; +use crate::theme::{self, with_alpha}; + +// ============================================================================= +// Color Theme Constants +// ============================================================================= +// +// Victorian cozy color palette - warm browns and creams for a study aesthetic. +// Matches the session picker and chat UI theming. + +/// Checkpoint button background when not selected: leather brown +const BUTTON_BG_UNSELECTED: Color = with_alpha(theme::LEATHER, 200); +/// Checkpoint button background when selected: mahogany (rich brown) +const BUTTON_BG_SELECTED: Color = theme::MAHOGANY; +/// Checkpoint button text: parchment for contrast +const BUTTON_TEXT: Color = theme::PARCHMENT; + +/// Result of user interaction with the checkpoint picker. +#[derive(Debug, Clone)] +pub enum CheckpointPickerResult { + /// User selected a checkpoint to restore (contains checkpoint_id) + Restore(String), + /// User cancelled the picker + Cancel, +} + +/// Checkpoint picker UI for selecting a checkpoint to restore. +#[derive(Debug)] +pub struct CheckpointPicker { + /// Root window widget + window: Handle, + /// Stack panel containing checkpoint items + checkpoint_list: Handle, + /// Button to restore selected checkpoint + restore_button: Handle, + /// Button to cancel + cancel_button: Handle, + /// Checkpoint item buttons (for detecting clicks) + checkpoint_buttons: Vec>, + /// Available checkpoints + checkpoints: Vec, + /// Currently selected checkpoint index + selected_index: Option, +} + +impl CheckpointPicker { + /// Create a new checkpoint picker with the given checkpoints. + /// + /// # Arguments + /// - `checkpoints` - Available checkpoints to display (oldest first) + /// - `ctx` - Build context for creating UI widgets + pub fn new(checkpoints: Vec, ctx: &mut BuildContext) -> Self { + let mut checkpoint_buttons = Vec::new(); + + // Create checkpoint list items + let checkpoint_list = StackPanelBuilder::new(WidgetBuilder::new()).build(ctx); + + // Create checkpoint item buttons (display newest first for better UX) + let reversed: Vec<_> = checkpoints.iter().rev().collect(); + for (display_idx, checkpoint) in reversed.iter().enumerate() { + let button = Self::create_checkpoint_item(ctx, checkpoint, display_idx == 0); + checkpoint_buttons.push(button); + } + // Reverse buttons so indices match original checkpoints order + checkpoint_buttons.reverse(); + + // Checkpoint list scroll viewer + let checkpoint_scroll = ScrollViewerBuilder::new( + WidgetBuilder::new() + .on_row(0) + .on_column(0), + ) + .with_content(checkpoint_list) + .build(ctx); + + // Restore button (starts disabled until selection) + let restore_button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(140.0) + .with_height(40.0) + .on_row(1) + .on_column(0) + .with_enabled(false), + ) + .with_text("Restore") + .build(ctx); + + // Cancel button + let cancel_button = ButtonBuilder::new( + WidgetBuilder::new() + .with_width(140.0) + .with_height(40.0) + .on_row(1) + .on_column(1), + ) + .with_text("Cancel") + .build(ctx); + + // Layout grid + let content = GridBuilder::new( + WidgetBuilder::new() + .with_margin(Thickness::uniform(10.0)) + .with_child(checkpoint_scroll) + .with_child(restore_button) + .with_child(cancel_button), + ) + .add_row(Row::stretch()) // Checkpoint list takes remaining space + .add_row(Row::strict(50.0)) // Button row + .add_column(Column::stretch()) // Restore button column + .add_column(Column::stretch()) // Cancel button column + .build(ctx); + + // Modal window + let window = WindowBuilder::new( + WidgetBuilder::new() + .with_width(400.0) + .with_height(350.0) + .with_desired_position(fyrox::core::algebra::Vector2::new(220.0, 120.0)), + ) + .with_title(WindowTitle::text("Restore to Checkpoint")) + .with_content(content) + .can_close(true) + .can_minimize(false) + .build(ctx); + + Self { + window, + checkpoint_list, + restore_button, + cancel_button, + checkpoint_buttons, + checkpoints, + selected_index: None, + } + } + + /// Populate the checkpoint list with items. + /// + /// Must be called after `new()` to add checkpoint buttons to the list. + pub fn populate(&self, ui: &mut UserInterface) { + // Add buttons in reverse order (newest first for display) + for &button in self.checkpoint_buttons.iter().rev() { + ui.send_message(WidgetMessage::link( + button, + MessageDirection::ToWidget, + self.checkpoint_list, + )); + } + } + + /// Create a checkpoint item widget (button styled as list item). + fn create_checkpoint_item( + ctx: &mut BuildContext, + checkpoint: &Checkpoint, + _is_first: bool, + ) -> Handle { + // Format: "Checkpoint #N - HH:MM:SS" + let time_str = checkpoint.timestamp.format("%H:%M:%S").to_string(); + let display_text = format!( + "Checkpoint #{} - {}", + checkpoint.index + 1, // 1-based for display + time_str + ); + + // Text content with parchment foreground for readability on dark background + let text = TextBuilder::new( + WidgetBuilder::new() + .with_margin(Thickness::uniform(6.0)) + .with_foreground(Brush::Solid(BUTTON_TEXT).into()), + ) + .with_text(&display_text) + .with_wrap(WrapMode::Word) + .build(ctx); + + // Button wrapping the text with dark background + ButtonBuilder::new( + WidgetBuilder::new() + .with_height(45.0) + .with_margin(Thickness::uniform(2.0)) + .with_background(Brush::Solid(BUTTON_BG_UNSELECTED).into()), + ) + .with_content(text) + .build(ctx) + } + + /// Handle UI messages and return result if user made a choice. + /// + /// Returns `Some(CheckpointPickerResult)` when user clicks a button. + pub fn handle_message( + &mut self, + message: &UiMessage, + ui: &mut UserInterface, + ) -> Option { + if let Some(ButtonMessage::Click) = message.data() { + let destination = message.destination(); + + // Check if cancel button was clicked + if destination == self.cancel_button { + return Some(CheckpointPickerResult::Cancel); + } + + // Check if restore button was clicked + if destination == self.restore_button { + if let Some(idx) = self.selected_index { + if let Some(checkpoint) = self.checkpoints.get(idx) { + return Some(CheckpointPickerResult::Restore( + checkpoint.checkpoint_id.clone(), + )); + } + } + } + + // Check if a checkpoint item was clicked + for (idx, &button) in self.checkpoint_buttons.iter().enumerate() { + if destination == button { + self.select_checkpoint(idx, ui); + return None; + } + } + } + + None + } + + /// Select a checkpoint by index. + fn select_checkpoint(&mut self, index: usize, ui: &mut UserInterface) { + // Update visual selection (deselect old, select new) + if let Some(old_idx) = self.selected_index { + self.update_item_style(old_idx, false, ui); + } + + self.selected_index = Some(index); + self.update_item_style(index, true, ui); + + // Enable restore button + ui.send_message(WidgetMessage::enabled( + self.restore_button, + MessageDirection::ToWidget, + true, + )); + } + + /// Update the visual style of a checkpoint item. + fn update_item_style(&self, index: usize, selected: bool, ui: &mut UserInterface) { + if let Some(&button) = self.checkpoint_buttons.get(index) { + let color = if selected { + BUTTON_BG_SELECTED + } else { + BUTTON_BG_UNSELECTED + }; + + ui.send_message(WidgetMessage::background( + button, + MessageDirection::ToWidget, + Brush::Solid(color).into(), + )); + } + } + + /// Get the window handle for visibility control. + pub fn window(&self) -> Handle { + self.window + } + + /// Close the picker window. + pub fn close(&self, ui: &mut UserInterface) { + ui.send_message(WidgetMessage::remove( + self.window, + MessageDirection::ToWidget, + )); + } + + /// Check if the picker has any checkpoints to display. + pub fn is_empty(&self) -> bool { + self.checkpoints.is_empty() + } + + /// Get the number of checkpoints. + pub fn checkpoint_count(&self) -> usize { + self.checkpoints.len() + } + + /// Get the currently selected checkpoint index (if any). + /// + /// This is the internal index (0 = oldest), not the display index. + pub fn selected_index(&self) -> Option { + self.selected_index + } + + /// Select a checkpoint by internal index (0 = oldest). + /// + /// This is a public wrapper around the private select_checkpoint method. + pub fn select_checkpoint_by_index(&mut self, index: usize, ui: &mut UserInterface) { + if index < self.checkpoints.len() { + self.select_checkpoint(index, ui); + } + } + + /// Get the checkpoint ID of the currently selected checkpoint. + /// + /// Returns None if no checkpoint is selected. + pub fn selected_checkpoint_id(&self) -> Option { + self.selected_index + .and_then(|idx| self.checkpoints.get(idx)) + .map(|cp| cp.checkpoint_id.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_checkpoint_picker_result_variants() { + let restore = CheckpointPickerResult::Restore("cp-123".to_string()); + let cancel = CheckpointPickerResult::Cancel; + + assert!(matches!(restore, CheckpointPickerResult::Restore(s) if s == "cp-123")); + assert!(matches!(cancel, CheckpointPickerResult::Cancel)); + } + + #[test] + fn test_checkpoint_picker_result_clone() { + let original = CheckpointPickerResult::Restore("test-checkpoint".to_string()); + let cloned = original.clone(); + + if let (CheckpointPickerResult::Restore(a), CheckpointPickerResult::Restore(b)) = + (original, cloned) + { + assert_eq!(a, b); + } else { + panic!("Clone should preserve variant"); + } + } + + #[test] + fn test_checkpoint_picker_result_debug() { + let result = CheckpointPickerResult::Cancel; + let debug_str = format!("{result:?}"); + assert!(debug_str.contains("Cancel")); + } +} diff --git a/tiny-workshop/game/src/debug_server/commands.rs b/tiny-workshop/game/src/debug_server/commands.rs index d33eada..e5b236b 100644 --- a/tiny-workshop/game/src/debug_server/commands.rs +++ b/tiny-workshop/game/src/debug_server/commands.rs @@ -64,6 +64,15 @@ pub enum DebugCommand { /// Query camera state (position, zoom, look target). QueryCamera { request_id: u64 }, + + /// Query checkpoint picker state (visible, checkpoints, selection). + QueryCheckpointPicker { request_id: u64 }, + + /// Perform an action on the checkpoint picker. + CheckpointPickerAction { + request_id: u64, + action: CheckpointPickerActionType, + }, } /// Types of actions that can be performed on the session picker. @@ -106,6 +115,20 @@ pub enum SettingsActionType { SimulateEscapeKey, } +/// Types of actions that can be performed on the checkpoint picker. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CheckpointPickerActionType { + /// Select a checkpoint by index (0-based, newest first in display order). + SelectCheckpoint { index: usize }, + /// Click the "Restore" button. + Restore, + /// Click the "Cancel" button. + Cancel, + /// Simulate double-Esc to open the checkpoint picker. + SimulateDoubleEsc, +} + /// Responses sent from the game loop back to the debug server. #[derive(Debug, Clone)] pub enum DebugResponse { @@ -186,6 +209,19 @@ pub enum DebugResponse { request_id: u64, state: CameraSnapshot, }, + + /// Checkpoint picker state response. + CheckpointPickerState { + request_id: u64, + state: CheckpointPickerSnapshot, + }, + + /// Checkpoint picker action completed. + CheckpointPickerActionDone { + request_id: u64, + success: bool, + error: Option, + }, } impl DebugResponse { @@ -205,6 +241,8 @@ impl DebugResponse { DebugResponse::SettingsDialogState { request_id, .. } => *request_id, DebugResponse::SettingsActionDone { request_id, .. } => *request_id, DebugResponse::CameraState { request_id, .. } => *request_id, + DebugResponse::CheckpointPickerState { request_id, .. } => *request_id, + DebugResponse::CheckpointPickerActionDone { request_id, .. } => *request_id, } } } @@ -304,6 +342,32 @@ pub struct CameraSnapshot { pub is_focused: bool, } +/// Snapshot of the checkpoint picker state. +#[derive(Debug, Clone, Serialize)] +pub struct CheckpointPickerSnapshot { + /// Whether the checkpoint picker is currently visible. + pub visible: bool, + + /// Available checkpoints (newest first in display order). + pub checkpoints: Vec, + + /// Currently selected checkpoint index (if any). + pub selected_index: Option, +} + +/// Information about a checkpoint for debug inspection. +#[derive(Debug, Clone, Serialize)] +pub struct CheckpointInfo { + /// Unique checkpoint ID (UUID from SDK). + pub checkpoint_id: String, + + /// ISO 8601 timestamp when checkpoint was created. + pub timestamp: String, + + /// Index in the original checkpoint list (0 = oldest). + pub index: usize, +} + /// Snapshot of the current game state. #[derive(Debug, Clone, Serialize)] pub struct GameStateSnapshot { @@ -327,6 +391,12 @@ pub struct GameStateSnapshot { /// Whether the chat UI has been initialized. pub has_chat_ui: bool, + + /// Whether the checkpoint picker is currently visible. + pub has_checkpoint_picker: bool, + + /// Number of checkpoints available in the current session. + pub checkpoint_count: usize, } /// A message in the conversation history. @@ -482,6 +552,24 @@ pub struct SettingsActionResponse { pub error: Option, } +/// POST /checkpoint-picker/action request body. +#[derive(Debug, Deserialize)] +pub struct CheckpointPickerActionRequest { + #[serde(flatten)] + pub action: CheckpointPickerActionType, +} + +/// POST /checkpoint-picker/action response body. +#[derive(Debug, Serialize)] +pub struct CheckpointPickerActionResponse { + /// Whether the operation succeeded. + pub success: bool, + + /// Error message if the operation failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -500,11 +588,14 @@ mod tests { waiting_for_api_key: false, current_response_length: 42, has_chat_ui: true, + has_checkpoint_picker: false, + checkpoint_count: 3, }; let json = serde_json::to_string(&state).unwrap(); assert!(json.contains("\"session_id\":\"test-123\"")); assert!(json.contains("\"is_authenticated\":true")); + assert!(json.contains("\"checkpoint_count\":3")); } #[test] @@ -517,6 +608,8 @@ mod tests { waiting_for_api_key: false, current_response_length: 0, has_chat_ui: false, + has_checkpoint_picker: false, + checkpoint_count: 0, }; let json = serde_json::to_string(&state).unwrap(); @@ -779,6 +872,8 @@ mod tests { waiting_for_api_key: false, current_response_length: 0, has_chat_ui: false, + has_checkpoint_picker: false, + checkpoint_count: 0, }, }, DebugResponse::Messages { diff --git a/tiny-workshop/game/src/debug_server/mod.rs b/tiny-workshop/game/src/debug_server/mod.rs index 851a21e..cd0ac6c 100644 --- a/tiny-workshop/game/src/debug_server/mod.rs +++ b/tiny-workshop/game/src/debug_server/mod.rs @@ -6,7 +6,7 @@ //! # Endpoints //! //! - `GET /health` - Health check -//! - `GET /state` - Query game state +//! - `GET /state` - Query game state (includes checkpoint info) //! - `POST /send` - Send user message //! - `GET /messages` - Get recent conversation messages //! - `GET /screenshot` - Capture screenshot (JPEG) @@ -17,6 +17,8 @@ //! - `GET /settings` - Query settings dialog state //! - `POST /settings/action` - Interact with settings dialog //! - `GET /camera` - Query camera state (position, zoom, focus) +//! - `GET /checkpoint-picker` - Query checkpoint picker state +//! - `POST /checkpoint-picker/action` - Interact with checkpoint picker //! //! # Example //! @@ -33,6 +35,12 @@ //! curl http://localhost:9000/camera //! curl -X POST http://localhost:9000/send -H "Content-Type: application/json" -d '{"content":"Hello!"}' //! curl http://localhost:9000/messages +//! +//! # Checkpoint picker testing +//! curl http://localhost:9000/checkpoint-picker +//! curl -X POST http://localhost:9000/checkpoint-picker/action -H "Content-Type: application/json" -d '{"type":"simulate_double_esc"}' +//! curl -X POST http://localhost:9000/checkpoint-picker/action -H "Content-Type: application/json" -d '{"type":"select_checkpoint","index":0}' +//! curl -X POST http://localhost:9000/checkpoint-picker/action -H "Content-Type: application/json" -d '{"type":"restore"}' //! ``` mod commands; @@ -40,9 +48,10 @@ pub(crate) mod screenshot; mod server; pub use commands::{ - CameraSnapshot, ConversationMessage, DebugCommand, DebugResponse, GameStateSnapshot, - MessageRole, MessagesResponse, SessionPickerActionType, SessionPickerSession, - SessionPickerSnapshot, SettingsActionType, SettingsDialogSnapshot, SettingsValues, + CameraSnapshot, CheckpointInfo, CheckpointPickerActionType, CheckpointPickerSnapshot, + ConversationMessage, DebugCommand, DebugResponse, GameStateSnapshot, MessageRole, + MessagesResponse, SessionPickerActionType, SessionPickerSession, SessionPickerSnapshot, + SettingsActionType, SettingsDialogSnapshot, SettingsValues, }; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/tiny-workshop/game/src/debug_server/server.rs b/tiny-workshop/game/src/debug_server/server.rs index d115166..9c99963 100644 --- a/tiny-workshop/game/src/debug_server/server.rs +++ b/tiny-workshop/game/src/debug_server/server.rs @@ -12,9 +12,10 @@ use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; use super::commands::{ - ClickHttpResponse, ClickRequest, DebugCommand, DebugResponse, HealthResponse, MessagesQuery, - SendMessageHttpResponse, SendMessageRequest, SessionPickerActionType, SettingsActionRequest, - SettingsActionResponse, UiVisibilityRequest, UiVisibilityResponse, + CheckpointPickerActionRequest, CheckpointPickerActionResponse, ClickHttpResponse, ClickRequest, + DebugCommand, DebugResponse, HealthResponse, MessagesQuery, SendMessageHttpResponse, + SendMessageRequest, SessionPickerActionType, SettingsActionRequest, SettingsActionResponse, + UiVisibilityRequest, UiVisibilityResponse, }; use super::next_request_id; @@ -72,6 +73,11 @@ pub async fn run_server( .route("/settings", get(settings_handler)) .route("/settings/action", post(settings_action_handler)) .route("/camera", get(camera_handler)) + .route("/checkpoint-picker", get(checkpoint_picker_handler)) + .route( + "/checkpoint-picker/action", + post(checkpoint_picker_action_handler), + ) .with_state(state); // Bind to localhost only for security @@ -704,3 +710,116 @@ async fn camera_handler( } } } + +/// GET /checkpoint-picker - Get checkpoint picker state. +async fn checkpoint_picker_handler( + State(state): State>, +) -> Result { + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + + // Register the pending request + { + let mut pending = state.pending_requests.lock().await; + pending.insert(request_id, tx); + } + + // Send the command to the game loop + state + .command_tx + .send(DebugCommand::QueryCheckpointPicker { request_id }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Wait for the response with a timeout + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(DebugResponse::CheckpointPickerState { + state: snapshot, .. + })) => Ok(Json(snapshot)), + Ok(Ok(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(Err(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => { + let mut pending = state.pending_requests.lock().await; + pending.remove(&request_id); + Err(StatusCode::GATEWAY_TIMEOUT) + } + } +} + +/// POST /checkpoint-picker/action - Perform checkpoint picker action. +async fn checkpoint_picker_action_handler( + State(state): State>, + Json(payload): Json, +) -> Result)> { + let request_id = next_request_id(); + let (tx, rx) = oneshot::channel(); + + // Register the pending request + { + let mut pending = state.pending_requests.lock().await; + pending.insert(request_id, tx); + } + + // Send the command to the game loop + state + .command_tx + .send(DebugCommand::CheckpointPickerAction { + request_id, + action: payload.action, + }) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(CheckpointPickerActionResponse { + success: false, + error: Some("Failed to send command to game loop".to_string()), + }), + ) + })?; + + // Wait for the response with a timeout + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(DebugResponse::CheckpointPickerActionDone { success, error, .. })) => { + if success { + Ok(Json(CheckpointPickerActionResponse { + success: true, + error: None, + })) + } else { + Err(( + StatusCode::BAD_REQUEST, + Json(CheckpointPickerActionResponse { + success: false, + error, + }), + )) + } + } + Ok(Ok(_)) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(CheckpointPickerActionResponse { + success: false, + error: Some("Unexpected response type".to_string()), + }), + )), + Ok(Err(_)) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(CheckpointPickerActionResponse { + success: false, + error: Some("Response channel closed".to_string()), + }), + )), + Err(_) => { + let mut pending = state.pending_requests.lock().await; + pending.remove(&request_id); + Err(( + StatusCode::GATEWAY_TIMEOUT, + Json(CheckpointPickerActionResponse { + success: false, + error: Some("Request timed out".to_string()), + }), + )) + } + } +} diff --git a/tiny-workshop/game/src/ipc.rs b/tiny-workshop/game/src/ipc.rs index 068813a..dddb2b5 100644 --- a/tiny-workshop/game/src/ipc.rs +++ b/tiny-workshop/game/src/ipc.rs @@ -74,6 +74,12 @@ pub enum FrontendMessage { /// SDK session ID to resume (for conversation continuity across restarts) #[serde(skip_serializing_if = "Option::is_none")] resume_session_id: Option, + /// If true, fork the session instead of continuing it (creates new session ID) + #[serde(skip_serializing_if = "Option::is_none")] + fork_session: Option, + /// Resume at specific message UUID instead of latest (for checkpoint restoration) + #[serde(skip_serializing_if = "Option::is_none")] + resume_at_message: Option, }, /// User sends a chat message UserMessage { content: String }, @@ -97,6 +103,13 @@ pub enum FrontendMessage { /// Human-readable result message (success description or error) message: String, }, + /// Restore to a previous checkpoint (fork session + rewind files) + Restore { + /// SDK session ID to restore from + session_id: String, + /// Checkpoint UUID to restore to (from checkpoint_created message) + checkpoint_id: String, + }, } // ============================================================================= @@ -173,6 +186,13 @@ pub enum BackendMessage { new_session_id: String, trigger: String, }, + /// Checkpoint was created (for restore functionality) + CheckpointCreated { + /// UUID from SDK user message - used as restore point identifier + checkpoint_id: String, + /// ISO 8601 timestamp when checkpoint was created + timestamp: String, + }, } // ============================================================================= @@ -190,6 +210,8 @@ mod tests { working_dir: "/test".to_string(), api_key: Some("zai-key-123".to_string()), resume_session_id: Some("sdk-session-456".to_string()), + fork_session: None, + resume_at_message: None, }; let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains(r#""type":"init""#)); @@ -206,6 +228,8 @@ mod tests { working_dir: "/test".to_string(), api_key: None, resume_session_id: None, + fork_session: None, + resume_at_message: None, }; let json = serde_json::to_string(&msg).unwrap(); // api_key and resume_session_id should be omitted when None @@ -213,6 +237,21 @@ mod tests { assert!(!json.contains("resume_session_id")); } + #[test] + fn test_serialize_init_with_checkpoint_options() { + let msg = FrontendMessage::Init { + version: 1, + working_dir: "/test".to_string(), + api_key: Some("zai-key-123".to_string()), + resume_session_id: Some("sdk-session-456".to_string()), + fork_session: Some(true), + resume_at_message: Some("checkpoint-uuid-789".to_string()), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""fork_session":true"#)); + assert!(json.contains(r#""resume_at_message":"checkpoint-uuid-789""#)); + } + #[test] fn test_serialize_user_message() { let msg = FrontendMessage::UserMessage { @@ -359,12 +398,18 @@ mod tests { working_dir: "/test".to_string(), api_key: None, resume_session_id: None, + fork_session: None, + resume_at_message: None, }, FrontendMessage::UserMessage { content: "Hi!".to_string(), }, FrontendMessage::Cancel, FrontendMessage::Shutdown, + FrontendMessage::Restore { + session_id: "session-123".to_string(), + checkpoint_id: "checkpoint-456".to_string(), + }, ]; for msg in messages { @@ -374,6 +419,40 @@ mod tests { } } + #[test] + fn test_serialize_restore_message() { + let msg = FrontendMessage::Restore { + session_id: "session-abc".to_string(), + checkpoint_id: "checkpoint-xyz".to_string(), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"restore""#)); + assert!(json.contains(r#""session_id":"session-abc""#)); + assert!(json.contains(r#""checkpoint_id":"checkpoint-xyz""#)); + } + + #[test] + fn test_deserialize_checkpoint_created() { + let json = r#"{ + "type": "checkpoint_created", + "checkpoint_id": "uuid-123-456", + "timestamp": "2026-01-15T12:00:00Z" + }"#; + + let msg: BackendMessage = serde_json::from_str(json).unwrap(); + + match msg { + BackendMessage::CheckpointCreated { + checkpoint_id, + timestamp, + } => { + assert_eq!(checkpoint_id, "uuid-123-456"); + assert_eq!(timestamp, "2026-01-15T12:00:00Z"); + } + _ => panic!("Expected CheckpointCreated variant"), + } + } + #[test] fn test_deserialize_compaction_occurred() { let json = r#"{ diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index 2a86e47..8f7df1d 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -18,6 +18,7 @@ mod debug_server; mod note_viewer; mod persistence; mod room; +mod checkpoint_picker; mod session_picker; mod settings_applier; mod settings_dialog; @@ -33,6 +34,7 @@ use credential_manager::{CredentialManager, StorageType}; use camera_animator::CameraAnimator; use cube_animator::CubeAnimator; use note_viewer::NoteViewer; +use checkpoint_picker::{CheckpointPicker, CheckpointPickerResult}; use session_picker::{SessionPicker, SessionPickerResult}; use settings_dialog::{SettingsDialog, SettingsDialogResult}; @@ -475,6 +477,16 @@ pub struct Game { #[reflect(hidden)] pending_close_settings: bool, + /// Flag to trigger checkpoint picker open from double-Esc + #[visit(skip)] + #[reflect(hidden)] + pending_open_checkpoint_picker: bool, + + /// Flag to trigger checkpoint picker close from Escape key + #[visit(skip)] + #[reflect(hidden)] + pending_close_checkpoint_picker: bool, + /// Track if Shift key is currently held #[visit(skip)] #[reflect(hidden)] @@ -665,6 +677,19 @@ pub struct Game { #[reflect(hidden)] waiting_for_session_choice: bool, + // ======================================================================== + // Checkpoint Restoration Fields + // ======================================================================== + /// Checkpoint picker UI (shown when user double-presses Esc) + #[visit(skip)] + #[reflect(hidden)] + checkpoint_picker: Option, + + /// Timestamp of last Esc key press (for double-Esc detection) + #[visit(skip)] + #[reflect(hidden)] + last_esc_time: Option, + // ======================================================================== // Debug Server Fields (only present when feature enabled) // ======================================================================== @@ -1122,6 +1147,22 @@ impl Plugin for Game { } } + // Handle pending checkpoint picker open (from double-Esc) + if self.pending_open_checkpoint_picker { + self.pending_open_checkpoint_picker = false; + self.open_checkpoint_picker(context); + } + + // Handle pending checkpoint picker close (from Escape key) + if self.pending_close_checkpoint_picker { + self.pending_close_checkpoint_picker = false; + if let Some(picker) = self.checkpoint_picker.take() { + let ui = context.user_interfaces.first_mut(); + picker.close(ui); + fyrox::core::log::Log::info("Checkpoint picker closed via Escape key"); + } + } + // ===================================================================== // Animation Update Pipeline (Event-Driven) // ===================================================================== @@ -1304,7 +1345,7 @@ impl Plugin for Game { } } - // Handle Escape key to close settings dialog + // Handle Escape key - close dialogs or detect double-Esc for checkpoint picker if let Event::WindowEvent { event: WindowEvent::KeyboardInput { @@ -1320,9 +1361,29 @@ impl Plugin for Game { .. } = event { - if self.settings_dialog.is_some() { + // Priority 1: Close checkpoint picker if open + if self.checkpoint_picker.is_some() { + self.pending_close_checkpoint_picker = true; + } + // Priority 2: Close settings dialog if open + else if self.settings_dialog.is_some() { self.pending_close_settings = true; } + // Priority 3: Detect double-Esc for checkpoint picker + else { + let now = std::time::Instant::now(); + if let Some(last_time) = self.last_esc_time { + // Double-Esc detected if within 500ms + if now.duration_since(last_time).as_millis() < 500 { + self.pending_open_checkpoint_picker = true; + self.last_esc_time = None; // Reset after detection + } else { + self.last_esc_time = Some(now); + } + } else { + self.last_esc_time = Some(now); + } + } } } @@ -1411,6 +1472,18 @@ impl Plugin for Game { } } + // Handle checkpoint picker messages + if let Some(picker) = &mut self.checkpoint_picker { + let ui = context.user_interfaces.first_mut(); + if let Some(result) = picker.handle_message(message, ui) { + // Close picker before handling result + picker.close(ui); + self.checkpoint_picker = None; + self.handle_checkpoint_picker_result(result, context); + return; // Don't process other UI while handling picker + } + } + // Handle chat UI messages if let Some(chat_ui) = &mut self.chat_ui { let ui = context.user_interfaces.first_mut(); @@ -2437,6 +2510,139 @@ impl Game { } } + /// Restore to a previous checkpoint. + /// + /// This will: + /// 1. Find the checkpoint in the current session log + /// 2. Restore the room state from the checkpoint's snapshot + /// 3. Send a restore message to the backend (which forks the session) + /// 4. Clear and re-display chat history up to the checkpoint + /// + /// # Arguments + /// - `checkpoint_id` - The UUID of the checkpoint to restore to + /// - `context` - Fyrox plugin context + /// + /// # Returns + /// - `Ok(())` - Restore initiated successfully + /// - `Err(String)` - Error description (checkpoint not found, no session, etc.) + fn restore_to_checkpoint( + &mut self, + checkpoint_id: &str, + context: &mut PluginContext, + ) -> Result<(), String> { + // Need active session to restore + let session_id = self + .session_id + .clone() + .ok_or("No active session to restore")?; + + // Need persistence manager to load session log + let pm = self + .persistence + .as_ref() + .ok_or("No persistence manager")?; + + // Load current session log + let sessions_dir = pm.sessions_dir(); + let current_session = self + .session_logger + .as_ref() + .map(|l| l.session_id()) + .ok_or("No session logger")?; + + let session_path = sessions_dir.join(format!("{current_session}.jsonl")); + let entries = persistence::load_session(&session_path) + .map_err(|e| format!("Failed to load session: {e}"))?; + + // Find the checkpoint + let checkpoint = persistence::find_checkpoint(&entries, checkpoint_id) + .ok_or_else(|| format!("Checkpoint not found: {checkpoint_id}"))?; + + fyrox::core::log::Log::info(format!( + "Restoring to checkpoint {} (index {})", + checkpoint_id, checkpoint.index + )); + + // Restore room state from snapshot if available + if let Some(ref snapshot) = checkpoint.room_snapshot { + if let Some(room_state) = persistence::restore_room_from_snapshot(snapshot) { + fyrox::core::log::Log::info("Restoring room state from checkpoint snapshot"); + self.room_manager = room_state.into_manager(); + // Sync visuals with restored room state + self.sync_room_visuals(context); + } else { + fyrox::core::log::Log::warn("Failed to restore room state from checkpoint snapshot"); + } + } + + // Send restore message to backend + if let Some(ref backend) = self.backend { + backend.send_restore(session_id, checkpoint_id.to_string()); + } + + // Clear chat UI and re-display history up to checkpoint + if let Some(ref mut chat_ui) = self.chat_ui { + let ui = context.user_interfaces.first_mut(); + chat_ui.clear_messages(ui); + + // Re-display messages up to (and including) the checkpoint + // We need to count checkpoint entries to know when to stop + let mut checkpoint_count = 0; + for entry in &entries { + match entry { + persistence::SessionLogEntry::UserMessage { content, .. } => { + chat_ui.add_user_message(content, ui); + } + persistence::SessionLogEntry::AssistantMessageComplete { full_content, .. } => { + chat_ui.add_assistant_message(full_content, ui); + } + persistence::SessionLogEntry::CheckpointCreated { + checkpoint_id: cp_id, + .. + } => { + if cp_id == checkpoint_id { + // Stop after displaying messages up to this checkpoint + break; + } + checkpoint_count += 1; + } + _ => {} + } + } + + fyrox::core::log::Log::info(format!( + "Chat UI restored to checkpoint {} (checkpoint index {})", + checkpoint_id, checkpoint_count + )); + } + + Ok(()) + } + + /// Get all checkpoints from the current session. + /// + /// Returns checkpoints in chronological order (oldest first). + /// Returns empty vec if no session is active. + fn get_current_session_checkpoints(&self) -> Vec { + let Some(pm) = &self.persistence else { + return vec![]; + }; + let Some(session_logger) = &self.session_logger else { + return vec![]; + }; + + let sessions_dir = pm.sessions_dir(); + let session_path = sessions_dir.join(format!("{}.jsonl", session_logger.session_id())); + + match persistence::load_session(&session_path) { + Ok(entries) => persistence::extract_checkpoints(&entries), + Err(e) => { + fyrox::core::log::Log::warn(format!("Failed to load session for checkpoints: {e}")); + vec![] + } + } + } + /// Update camera zoom and look direction based on animator state fn update_camera(&mut self, context: &mut PluginContext) { if let Some(scene) = context.scenes.try_get_mut(self.scene) { @@ -2935,6 +3141,30 @@ impl Game { timestamp: Utc::now(), }); } + + BackendMessage::CheckpointCreated { + checkpoint_id, + timestamp, + } => { + fyrox::core::log::Log::info(format!( + "Checkpoint created: {} at {}", + checkpoint_id, timestamp + )); + + // Snapshot current room state for restoration + let room_state = persistence::RoomState::from_manager(&self.room_manager); + let room_snapshot = persistence::snapshot_room_state(&room_state); + + // Log checkpoint entry to session log with room snapshot + self.log_session_entry(persistence::SessionLogEntry::CheckpointCreated { + checkpoint_id: checkpoint_id.clone(), + room_snapshot, + timestamp: Utc::now(), // Use our timestamp for consistency + }); + + // Store checkpoint ID for potential restore UI access + // (The checkpoint picker will read these from the session log) + } } // Sync room visuals if needed (after match to avoid ui borrow conflict) @@ -3019,6 +3249,58 @@ impl Game { fyrox::core::log::Log::info("Settings dialog opened"); } + /// Open the checkpoint picker (triggered by double-Esc). + fn open_checkpoint_picker(&mut self, context: &mut PluginContext) { + // Don't open if already open + if self.checkpoint_picker.is_some() { + return; + } + + // Get checkpoints from current session + let checkpoints = self.get_current_session_checkpoints(); + + // Don't open if no checkpoints + if checkpoints.is_empty() { + fyrox::core::log::Log::info("No checkpoints available to restore"); + return; + } + + let ui = context.user_interfaces.first_mut(); + let picker = CheckpointPicker::new(checkpoints, &mut ui.build_ctx()); + picker.populate(ui); + + fyrox::core::log::Log::info(format!( + "Checkpoint picker opened with {} checkpoints", + picker.checkpoint_count() + )); + + self.checkpoint_picker = Some(picker); + } + + /// Handle checkpoint picker result and initiate restore if selected. + fn handle_checkpoint_picker_result( + &mut self, + result: CheckpointPickerResult, + context: &mut PluginContext, + ) { + match result { + CheckpointPickerResult::Restore(checkpoint_id) => { + fyrox::core::log::Log::info(format!( + "User selected checkpoint to restore: {}", + checkpoint_id + )); + + // Initiate restore + if let Err(e) = self.restore_to_checkpoint(&checkpoint_id, context) { + fyrox::core::log::Log::err(format!("Failed to restore to checkpoint: {e}")); + } + } + CheckpointPickerResult::Cancel => { + fyrox::core::log::Log::info("Checkpoint picker cancelled"); + } + } + } + /// Apply graphics settings to the Fyrox renderer. /// /// Converts our QualityPreset to Fyrox's QualitySettings and applies them. @@ -3137,6 +3419,7 @@ impl Game { context: &mut PluginContext, ) { use debug_server::{ + CheckpointInfo, CheckpointPickerActionType, CheckpointPickerSnapshot, ConversationMessage, DebugCommand, DebugResponse, GameStateSnapshot, MessageRole, MessagesResponse, SessionPickerActionType, SessionPickerSession, SessionPickerSnapshot, }; @@ -3187,6 +3470,7 @@ impl Game { } DebugCommand::QueryState { request_id } => { + let checkpoint_count = self.get_current_session_checkpoints().len(); let state = GameStateSnapshot { session_id: self.session_id.clone(), is_authenticated: self.is_authenticated, @@ -3195,6 +3479,8 @@ impl Game { waiting_for_api_key: self.waiting_for_api_key, current_response_length: self.current_response.len(), has_chat_ui: self.chat_ui.is_some(), + has_checkpoint_picker: self.checkpoint_picker.is_some(), + checkpoint_count, }; handle.send_response(DebugResponse::State { request_id, state }); } @@ -3566,6 +3852,104 @@ debug_server::SettingsActionType::Save => { state: snapshot, }); } + + DebugCommand::QueryCheckpointPicker { request_id } => { + // Get checkpoints from current session + let checkpoints = self.get_current_session_checkpoints(); + + // Build checkpoint info list (reversed for newest-first display) + let checkpoint_infos: Vec = checkpoints + .iter() + .rev() + .map(|cp| CheckpointInfo { + checkpoint_id: cp.checkpoint_id.clone(), + timestamp: cp.timestamp.to_rfc3339(), + index: cp.index, + }) + .collect(); + + // Get selected index from picker if open + let selected_index = self + .checkpoint_picker + .as_ref() + .and_then(|p| p.selected_index()); + + let snapshot = CheckpointPickerSnapshot { + visible: self.checkpoint_picker.is_some(), + checkpoints: checkpoint_infos, + selected_index, + }; + + handle.send_response(DebugResponse::CheckpointPickerState { + request_id, + state: snapshot, + }); + } + + DebugCommand::CheckpointPickerAction { request_id, action } => { + let (success, error) = match action { + CheckpointPickerActionType::SimulateDoubleEsc => { + // Trigger the checkpoint picker to open + self.pending_open_checkpoint_picker = true; + (true, None) + } + + CheckpointPickerActionType::SelectCheckpoint { index } => { + if let Some(picker) = &mut self.checkpoint_picker { + let ui = context.user_interfaces.first_mut(); + // The display index is reversed (newest first), so convert back + let checkpoint_count = picker.checkpoint_count(); + if index < checkpoint_count { + // Convert display index to internal index + let internal_index = checkpoint_count - 1 - index; + picker.select_checkpoint_by_index(internal_index, ui); + (true, None) + } else { + (false, Some(format!("Invalid checkpoint index: {index}"))) + } + } else { + (false, Some("Checkpoint picker not open".to_string())) + } + } + + CheckpointPickerActionType::Restore => { + if let Some(picker) = &self.checkpoint_picker { + if let Some(checkpoint_id) = picker.selected_checkpoint_id() { + // Close picker first + let ui = context.user_interfaces.first_mut(); + picker.close(ui); + self.checkpoint_picker = None; + + // Perform restore + match self.restore_to_checkpoint(&checkpoint_id, context) { + Ok(()) => (true, None), + Err(e) => (false, Some(e)), + } + } else { + (false, Some("No checkpoint selected".to_string())) + } + } else { + (false, Some("Checkpoint picker not open".to_string())) + } + } + + CheckpointPickerActionType::Cancel => { + if let Some(picker) = self.checkpoint_picker.take() { + let ui = context.user_interfaces.first_mut(); + picker.close(ui); + (true, None) + } else { + (false, Some("Checkpoint picker not open".to_string())) + } + } + }; + + handle.send_response(DebugResponse::CheckpointPickerActionDone { + request_id, + success, + error, + }); + } } } diff --git a/tiny-workshop/game/src/persistence/mod.rs b/tiny-workshop/game/src/persistence/mod.rs index f308b86..b57d647 100644 --- a/tiny-workshop/game/src/persistence/mod.rs +++ b/tiny-workshop/game/src/persistence/mod.rs @@ -81,13 +81,15 @@ pub mod settings; // Re-export public types pub use error::PersistenceError; pub use lock::ProjectLock; +pub use room_persistence::{restore_room_from_snapshot, snapshot_room_state}; pub use schema::{ProjectState, RoomState}; // Note: MAX_LOG_BYTES and MAX_LOG_ENTRIES are public API for external callers // (e.g., displaying limits in settings UI). Currently used internally by SessionLogger methods. #[allow(unused_imports)] pub use session_log::{ - extract_sdk_session_id, load_session, load_session_summaries, SessionLogEntry, SessionLogger, - SessionSummary, MAX_LOG_BYTES, MAX_LOG_ENTRIES, + extract_checkpoints, extract_sdk_session_id, find_checkpoint, load_session, + load_session_summaries, Checkpoint, SessionLogEntry, SessionLogger, SessionSummary, + MAX_LOG_BYTES, MAX_LOG_ENTRIES, }; // SchemaVersion is intentionally not re-exported for now (internal use only) diff --git a/tiny-workshop/game/src/persistence/room_persistence.rs b/tiny-workshop/game/src/persistence/room_persistence.rs index 05734cc..b325748 100644 --- a/tiny-workshop/game/src/persistence/room_persistence.rs +++ b/tiny-workshop/game/src/persistence/room_persistence.rs @@ -121,6 +121,36 @@ pub fn load_room(project_dir: &Path) -> Result { Ok(state) } +/// Create a JSON snapshot of the current room state for checkpoint storage. +/// +/// This serializes the room state to a `serde_json::Value` that can be +/// embedded in session log entries for checkpoint restoration. +/// +/// # Arguments +/// - `state` - The room state to snapshot +/// +/// # Returns +/// - `Some(Value)` - JSON representation of the room state +/// - `None` - Serialization failed (should not happen with valid RoomState) +pub fn snapshot_room_state(state: &RoomState) -> Option { + serde_json::to_value(state).ok() +} + +/// Restore room state from a JSON snapshot. +/// +/// This deserializes a room state from a `serde_json::Value` that was +/// previously created by [`snapshot_room_state`]. +/// +/// # Arguments +/// - `snapshot` - JSON value containing the room state +/// +/// # Returns +/// - `Some(RoomState)` - Successfully deserialized room state +/// - `None` - Deserialization failed (invalid or corrupted snapshot) +pub fn restore_room_from_snapshot(snapshot: &serde_json::Value) -> Option { + serde_json::from_value(snapshot.clone()).ok() +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +238,58 @@ mod tests { // Pretty-printed JSON has newlines assert!(json.contains('\n'), "JSON should be pretty-printed"); } + + #[test] + fn test_snapshot_room_state() { + let state = RoomState::default(); + + let snapshot = snapshot_room_state(&state); + assert!(snapshot.is_some()); + + let snapshot = snapshot.unwrap(); + assert!(snapshot.is_object()); + assert!(snapshot.get("version").is_some()); + assert!(snapshot.get("surfaces").is_some()); + } + + #[test] + fn test_snapshot_and_restore_roundtrip() { + let state = RoomState::default(); + + // Snapshot it + let snapshot = snapshot_room_state(&state).unwrap(); + + // Restore it + let restored = restore_room_from_snapshot(&snapshot); + assert!(restored.is_some()); + + let restored = restored.unwrap(); + assert_eq!(restored.version, state.version); + assert_eq!(restored.surfaces.len(), state.surfaces.len()); + } + + #[test] + fn test_restore_from_invalid_snapshot() { + // With #[serde(default)] on RoomState fields, ANY valid JSON object + // will deserialize successfully to default values. This is intentional + // for backwards compatibility. We test that truly invalid JSON fails. + + // Not a JSON object (array) + let array = serde_json::json!([1, 2, 3]); + let restored = restore_room_from_snapshot(&array); + assert!(restored.is_none()); + + // Primitive value (not an object) + let string = serde_json::json!("just a string"); + let restored = restore_room_from_snapshot(&string); + assert!(restored.is_none()); + + // Invalid nested structure (surfaces should be a map of Surface objects) + let bad_surfaces = serde_json::json!({ + "version": {"major": 1, "minor": 1}, + "surfaces": "not a map" + }); + let restored = restore_room_from_snapshot(&bad_surfaces); + assert!(restored.is_none()); + } } diff --git a/tiny-workshop/game/src/persistence/session_log.rs b/tiny-workshop/game/src/persistence/session_log.rs index 17a44cc..b3a72c4 100644 --- a/tiny-workshop/game/src/persistence/session_log.rs +++ b/tiny-workshop/game/src/persistence/session_log.rs @@ -123,6 +123,20 @@ pub enum SessionLogEntry { timestamp: DateTime, }, + /// Checkpoint created (for restore functionality) + /// + /// Created when the SDK provides a checkpoint UUID with a user message. + /// Includes a snapshot of the room state at this point for restoration. + CheckpointCreated { + /// UUID from SDK user message - used as restore point identifier + checkpoint_id: String, + /// Snapshot of room state at this checkpoint (serialized room.json) + #[serde(skip_serializing_if = "Option::is_none")] + room_snapshot: Option, + /// When the checkpoint was created + timestamp: DateTime, + }, + // === Messages === /// User sent a message UserMessage { @@ -633,6 +647,70 @@ pub fn extract_sdk_session_id(entries: &[SessionLogEntry]) -> Option { sdk_session_id } +/// A checkpoint extracted from session log entries. +/// +/// Contains the checkpoint ID, timestamp, and optional room snapshot +/// for restore functionality. +#[derive(Debug, Clone)] +pub struct Checkpoint { + /// UUID from SDK user message - used as restore point identifier + pub checkpoint_id: String, + /// When the checkpoint was created + pub timestamp: DateTime, + /// Snapshot of room state at this checkpoint (if available) + pub room_snapshot: Option, + /// Index of this checkpoint in the session (0 = first) + pub index: usize, +} + +/// Extract all checkpoints from session log entries. +/// +/// This returns checkpoints in chronological order (oldest first). +/// +/// # Arguments +/// - `entries` - The log entries from the session +/// +/// # Returns +/// Vector of checkpoints, may be empty if no checkpoints exist +pub fn extract_checkpoints(entries: &[SessionLogEntry]) -> Vec { + let mut checkpoints = Vec::new(); + let mut index = 0; + + for entry in entries { + if let SessionLogEntry::CheckpointCreated { + checkpoint_id, + room_snapshot, + timestamp, + } = entry + { + checkpoints.push(Checkpoint { + checkpoint_id: checkpoint_id.clone(), + timestamp: *timestamp, + room_snapshot: room_snapshot.clone(), + index, + }); + index += 1; + } + } + + checkpoints +} + +/// Find a specific checkpoint by ID from session log entries. +/// +/// # Arguments +/// - `entries` - The log entries from the session +/// - `checkpoint_id` - The checkpoint UUID to find +/// +/// # Returns +/// - `Some(Checkpoint)` - The checkpoint with matching ID +/// - `None` - No checkpoint with that ID found +pub fn find_checkpoint(entries: &[SessionLogEntry], checkpoint_id: &str) -> Option { + extract_checkpoints(entries) + .into_iter() + .find(|c| c.checkpoint_id == checkpoint_id) +} + #[cfg(test)] mod tests { use super::*; @@ -1488,4 +1566,156 @@ this is not valid json super::MAX_LOG_ENTRIES ); } + + #[test] + fn test_checkpoint_created_serialization() { + let room_snapshot = serde_json::json!({ + "version": {"major": 1, "minor": 1}, + "surfaces": { + "desk": { + "grid": {"rows": 3, "cols": 4}, + "slots": [] + } + } + }); + + let entry = SessionLogEntry::CheckpointCreated { + checkpoint_id: "uuid-abc-123".to_string(), + room_snapshot: Some(room_snapshot), + timestamp: Utc::now(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"type\":\"checkpoint_created\"")); + assert!(json.contains("\"checkpoint_id\":\"uuid-abc-123\"")); + assert!(json.contains("\"room_snapshot\"")); + } + + #[test] + fn test_checkpoint_created_roundtrip() { + let entry = SessionLogEntry::CheckpointCreated { + checkpoint_id: "uuid-xyz-789".to_string(), + room_snapshot: Some(serde_json::json!({"test": "data"})), + timestamp: Utc::now(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: SessionLogEntry = serde_json::from_str(&json).unwrap(); + + if let SessionLogEntry::CheckpointCreated { + checkpoint_id, + room_snapshot, + .. + } = parsed + { + assert_eq!(checkpoint_id, "uuid-xyz-789"); + assert!(room_snapshot.is_some()); + } else { + panic!("Wrong variant"); + } + } + + #[test] + fn test_checkpoint_created_without_room_snapshot() { + // Room snapshot is optional + let entry = SessionLogEntry::CheckpointCreated { + checkpoint_id: "uuid-no-room".to_string(), + room_snapshot: None, + timestamp: Utc::now(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + // Should not contain room_snapshot when None + assert!(!json.contains("room_snapshot")); + + let parsed: SessionLogEntry = serde_json::from_str(&json).unwrap(); + if let SessionLogEntry::CheckpointCreated { room_snapshot, .. } = parsed { + assert!(room_snapshot.is_none()); + } else { + panic!("Wrong variant"); + } + } + + #[test] + fn test_extract_checkpoints() { + let entries = vec![ + SessionLogEntry::SessionStarted { + sdk_session_id: "sdk-123".to_string(), + model: None, + tools: vec![], + timestamp: Utc::now(), + }, + SessionLogEntry::UserMessage { + content: "Hello".to_string(), + timestamp: Utc::now(), + }, + SessionLogEntry::CheckpointCreated { + checkpoint_id: "cp-1".to_string(), + room_snapshot: Some(serde_json::json!({"test": 1})), + timestamp: Utc::now(), + }, + SessionLogEntry::AssistantMessageComplete { + full_content: "Hi".to_string(), + timestamp: Utc::now(), + }, + SessionLogEntry::CheckpointCreated { + checkpoint_id: "cp-2".to_string(), + room_snapshot: Some(serde_json::json!({"test": 2})), + timestamp: Utc::now(), + }, + ]; + + let checkpoints = extract_checkpoints(&entries); + assert_eq!(checkpoints.len(), 2); + assert_eq!(checkpoints[0].checkpoint_id, "cp-1"); + assert_eq!(checkpoints[0].index, 0); + assert_eq!(checkpoints[1].checkpoint_id, "cp-2"); + assert_eq!(checkpoints[1].index, 1); + } + + #[test] + fn test_extract_checkpoints_empty() { + let entries = vec![ + SessionLogEntry::SessionStarted { + sdk_session_id: "sdk-123".to_string(), + model: None, + tools: vec![], + timestamp: Utc::now(), + }, + SessionLogEntry::UserMessage { + content: "Hello".to_string(), + timestamp: Utc::now(), + }, + ]; + + let checkpoints = extract_checkpoints(&entries); + assert!(checkpoints.is_empty()); + } + + #[test] + fn test_find_checkpoint() { + let entries = vec![ + SessionLogEntry::CheckpointCreated { + checkpoint_id: "cp-1".to_string(), + room_snapshot: Some(serde_json::json!({"surface": "desk"})), + timestamp: Utc::now(), + }, + SessionLogEntry::CheckpointCreated { + checkpoint_id: "cp-2".to_string(), + room_snapshot: Some(serde_json::json!({"surface": "shelf"})), + timestamp: Utc::now(), + }, + ]; + + // Find existing checkpoint + let found = find_checkpoint(&entries, "cp-2"); + assert!(found.is_some()); + let found = found.unwrap(); + assert_eq!(found.checkpoint_id, "cp-2"); + assert_eq!(found.index, 1); + + // Find non-existing checkpoint + let not_found = find_checkpoint(&entries, "cp-99"); + assert!(not_found.is_none()); + } } diff --git a/tiny-workshop/game/tests/ipc_integration.rs b/tiny-workshop/game/tests/ipc_integration.rs index a74f4a0..add846d 100644 --- a/tiny-workshop/game/tests/ipc_integration.rs +++ b/tiny-workshop/game/tests/ipc_integration.rs @@ -307,6 +307,8 @@ fn test_frontend_message_json_lines_format() { working_dir: "/test".to_string(), api_key: None, resume_session_id: None, + fork_session: None, + resume_at_message: None, }, FrontendMessage::UserMessage { content: "Hello".to_string(),