import { writable, derived, get } from "svelte/store"; import { virtualFileSystem } from "./virtual-fs"; import { websocketService } from "./websocket"; export interface ContentState { content: string; language: string; theme: string; lastModified: Date; version: number; isUISynced: boolean; isAgentSynced: boolean; lastSource: "ui" | "agent" | "init"; isConflicted: boolean; } export interface ContentChange { content: string; source: "ui" | "agent" | "init"; timestamp: Date; version: number; } /** * ContentManager: Single source of truth for editor content * Manages bidirectional sync between UI and agent with conflict resolution */ class ContentManager { private static instance: ContentManager | null = null; private readonly DEFAULT_CONTENT = ` `; private readonly contentStore = writable({ content: this.DEFAULT_CONTENT, language: "html", theme: "vs-dark", lastModified: new Date(), version: 1, isUISynced: true, isAgentSynced: true, lastSource: "init", isConflicted: false, }); private syncTimeout: number | null = null; private readonly DEBOUNCE_MS = 300; private isUpdating = false; private agentEditInProgress = false; private lastAgentVersion = 0; private constructor() { this.setupSyncSubscription(); } static getInstance(): ContentManager { if (!ContentManager.instance) { ContentManager.instance = new ContentManager(); } return ContentManager.instance; } /** * Public reactive store for UI components */ readonly content = derived(this.contentStore, ($state) => ({ content: $state.content, language: $state.language, theme: $state.theme, lastModified: $state.lastModified, version: $state.version, })); /** * Public store subscription method */ subscribe = this.content.subscribe; /** * Update content from UI (Monaco editor) * Debounced for smooth typing experience */ updateFromUI(content: string): void { if (this.isUpdating || this.agentEditInProgress) { // Agent is editing, mark as conflicted if (this.agentEditInProgress) { this.contentStore.update((state) => ({ ...state, isConflicted: true, })); } return; } this.updateContent(content, "ui"); this.debouncedAgentSync(); } /** * Update content from agent (MCP tools) * Now handles version conflicts more gracefully */ updateFromAgent(content: string): void { if (this.isUpdating) return; this.isUpdating = true; this.agentEditInProgress = true; const currentState = get(this.contentStore); // Check for version conflict if ( currentState.version > this.lastAgentVersion && currentState.lastSource === "ui" ) { // UI has made changes since agent started editing console.warn("Agent edit conflicted with UI changes - agent wins"); this.contentStore.update((state) => ({ ...state, isConflicted: true, })); } this.updateContent(content, "agent"); this.lastAgentVersion = get(this.contentStore).version; this.clearSyncTimeout(); // Allow UI to resume editing after a short delay setTimeout(() => { this.agentEditInProgress = false; this.contentStore.update((state) => ({ ...state, isConflicted: false, })); }, 500); this.isUpdating = false; } /** * Initialize content (on app start) */ initialize(content?: string): void { const initialContent = content || this.DEFAULT_CONTENT; this.updateContent(initialContent, "init"); // Sync to VFS immediately but not to WebSocket yet (may not be connected) virtualFileSystem.updateGameContent(initialContent); this.contentStore.update((state) => ({ ...state, isAgentSynced: true, isConflicted: false, })); // Initialize version tracking this.lastAgentVersion = 1; } /** * Get current content (synchronous) */ getCurrentContent(): string { return get(this.contentStore).content; } /** * Get current state (synchronous) */ getCurrentState(): ContentState { return get(this.contentStore); } /** * Check if agent is currently editing */ isAgentEditing(): boolean { return this.agentEditInProgress; } /** * Force full sync (for reconnection scenarios) */ forceFullSync(): void { this.immediateFullSync(); } /** * Update language setting */ setLanguage(language: string): void { this.contentStore.update((state) => ({ ...state, language, lastModified: new Date(), })); } /** * Update theme setting */ setTheme(theme: string): void { this.contentStore.update((state) => ({ ...state, theme, lastModified: new Date(), })); } /** * Reset to default content */ reset(): void { this.updateContent(this.DEFAULT_CONTENT, "init"); this.immediateFullSync(); } private updateContent( content: string, source: ContentChange["source"], ): void { this.contentStore.update((state) => { // Prevent unnecessary updates if (state.content === content) return state; return { ...state, content, lastModified: new Date(), version: state.version + 1, isUISynced: source === "ui" || source === "init", isAgentSynced: source === "agent" || source === "init", lastSource: source, isConflicted: false, }; }); } private setupSyncSubscription(): void { this.contentStore.subscribe((state) => { // Only sync if content actually changed and we're not in an update cycle if (!this.isUpdating) { if (!state.isAgentSynced) { this.syncToAgent(state.content); } } }); } private debouncedAgentSync(): void { this.clearSyncTimeout(); this.syncTimeout = window.setTimeout(() => { const state = get(this.contentStore); if (!state.isAgentSynced) { this.syncToAgent(state.content); } }, this.DEBOUNCE_MS); } private immediateFullSync(): void { this.clearSyncTimeout(); const content = get(this.contentStore).content; this.syncToAgent(content); } private syncToAgent(content: string): void { // Update virtual file system virtualFileSystem.updateGameContent(content); // Send to WebSocket if connected if (websocketService.isConnected()) { websocketService.send({ type: "editor_sync", payload: { content }, timestamp: Date.now(), }); } // Mark as synced this.contentStore.update((state) => ({ ...state, isAgentSynced: true, })); } private clearSyncTimeout(): void { if (this.syncTimeout !== null) { clearTimeout(this.syncTimeout); this.syncTimeout = null; } } } export const contentManager = ContentManager.getInstance();