Spaces:
Running
Running
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 = `<world canvas="#game-canvas" sky="#87ceeb"> | |
<!-- Ground --> | |
<static-part pos="0 -0.5 0" shape="box" size="20 1 20" color="#90ee90"></static-part> | |
<!-- Ball --> | |
<dynamic-part pos="-2 4 -3" shape="sphere" size="1" color="#ff4500"></dynamic-part> | |
</world> | |
<script> | |
console.log("Game script loaded!"); | |
</script>`; | |
private readonly contentStore = writable<ContentState>({ | |
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(); | |