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();