diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/save-indicator.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/save-indicator.tsx
new file mode 100644
index 0000000000..da44623d67
--- /dev/null
+++ b/apps/web/client/src/app/project/[id]/_components/top-bar/save-indicator.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useEditorEngine } from '@/components/store/editor';
+import type { SaveState } from '@/components/store/editor/save-state';
+import { Icons } from '@onlook/ui/icons';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip';
+import { observer } from 'mobx-react-lite';
+
+export const SaveIndicator = observer(() => {
+ const editorEngine = useEditorEngine();
+ const saveState: SaveState = editorEngine.saveState.saveState;
+
+ const getIndicatorContent = () => {
+ switch (saveState) {
+ case 'saving':
+ return (
+
+
+ Saving...
+
+ );
+ case 'saved':
+ return (
+
+
+ Saved
+
+ );
+ case 'unsaved':
+ return (
+
+
+ Unsaved changes
+
+ );
+ }
+ };
+
+ const getTooltipContent = () => {
+ switch (saveState) {
+ case 'saving':
+ return 'Saving your changes...';
+ case 'saved':
+ return `Last saved ${editorEngine.saveState.formattedTimeSinceLastSave}`;
+ case 'unsaved':
+ return 'You have unsaved changes';
+ }
+ };
+
+ return (
+
+
+
+ {getIndicatorContent()}
+
+
+
+ {getTooltipContent()}
+
+
+ );
+});
diff --git a/apps/web/client/src/components/store/editor/code/index.ts b/apps/web/client/src/components/store/editor/code/index.ts
index ac54a761e8..55c8249a9e 100644
--- a/apps/web/client/src/components/store/editor/code/index.ts
+++ b/apps/web/client/src/components/store/editor/code/index.ts
@@ -24,6 +24,7 @@ export class CodeManager {
}
async write(action: Action) {
+ this.editorEngine.saveState.startSaving();
try {
// TODO: This is a hack to write code, we should refactor this
if (action.type === 'write-code' && action.diffs[0]) {
@@ -36,12 +37,14 @@ export class CodeManager {
const requests = await this.collectRequests(action);
await this.writeRequest(requests);
}
+ this.editorEngine.saveState.debouncedCompleteSave();
} catch (error) {
console.error('Error writing requests:', error);
toast.error('Error writing requests', {
description: error instanceof Error ? error.message : 'Unknown error',
});
this.editorEngine.branches.activeError.addCodeApplicationError(error instanceof Error ? error.message : 'Unknown error', action);
+ this.editorEngine.saveState.markUnsaved();
}
}
diff --git a/apps/web/client/src/components/store/editor/engine.ts b/apps/web/client/src/components/store/editor/engine.ts
index 4275fc237a..1987dd7bde 100644
--- a/apps/web/client/src/components/store/editor/engine.ts
+++ b/apps/web/client/src/components/store/editor/engine.ts
@@ -29,6 +29,7 @@ import { StateManager } from './state';
import { StyleManager } from './style';
import { TextEditingManager } from './text';
import { ThemeManager } from './theme';
+import { SaveStateManager } from './save-state';
export class EditorEngine {
readonly projectId: string;
@@ -71,6 +72,7 @@ export class EditorEngine {
readonly snap: SnapManager = new SnapManager(this);
readonly api: ApiManager = new ApiManager(this);
readonly ide: IdeManager = new IdeManager(this);
+ readonly saveState: SaveStateManager = new SaveStateManager(this);
constructor(projectId: string, posthog: PostHog) {
this.projectId = projectId;
@@ -114,6 +116,7 @@ export class EditorEngine {
this.frameEvent.clear();
this.screenshot.clear();
this.snap.hideSnapLines();
+ this.saveState.clear();
}
clearUI() {
diff --git a/apps/web/client/src/components/store/editor/save-state/index.ts b/apps/web/client/src/components/store/editor/save-state/index.ts
new file mode 100644
index 0000000000..a9b8e6a08b
--- /dev/null
+++ b/apps/web/client/src/components/store/editor/save-state/index.ts
@@ -0,0 +1,85 @@
+import { makeAutoObservable } from 'mobx';
+import type { EditorEngine } from '@/components/store/editor/engine';
+
+export type SaveState = 'saved' | 'saving' | 'unsaved';
+
+export class SaveStateManager {
+ saveState: SaveState = 'saved';
+ private saveTimeout: NodeJS.Timeout | null = null;
+ private lastSaveTime: number = Date.now();
+
+ constructor(private editorEngine: EditorEngine) {
+ makeAutoObservable(this);
+ }
+
+ /**
+ * Mark that a save operation has started
+ */
+ startSaving() {
+ this.saveState = 'saving';
+ if (this.saveTimeout) {
+ clearTimeout(this.saveTimeout);
+ this.saveTimeout = null;
+ }
+ }
+
+ /**
+ * Mark that a save operation has completed successfully
+ */
+ completeSave() {
+ this.saveState = 'saved';
+ this.lastSaveTime = Date.now();
+ }
+
+ /**
+ * Mark that there are unsaved changes
+ */
+ markUnsaved() {
+ // Only mark as unsaved if we're not currently saving
+ if (this.saveState !== 'saving') {
+ this.saveState = 'unsaved';
+ }
+ }
+
+ /**
+ * Debounced save completion - waits for a brief period after save
+ * to ensure no additional writes are happening
+ */
+ debouncedCompleteSave(delay: number = 300) {
+ if (this.saveTimeout) {
+ clearTimeout(this.saveTimeout);
+ }
+
+ this.saveTimeout = setTimeout(() => {
+ this.completeSave();
+ this.saveTimeout = null;
+ }, delay);
+ }
+
+ /**
+ * Get time since last save in seconds
+ */
+ get timeSinceLastSave(): number {
+ return Math.floor((Date.now() - this.lastSaveTime) / 1000);
+ }
+
+ /**
+ * Get formatted time since last save (e.g., "2 seconds ago", "1 minute ago")
+ */
+ get formattedTimeSinceLastSave(): string {
+ const seconds = this.timeSinceLastSave;
+ if (seconds < 60) {
+ return seconds === 1 ? '1 second ago' : `${seconds} seconds ago`;
+ }
+ const minutes = Math.floor(seconds / 60);
+ return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
+ }
+
+ clear() {
+ if (this.saveTimeout) {
+ clearTimeout(this.saveTimeout);
+ this.saveTimeout = null;
+ }
+ this.saveState = 'saved';
+ }
+}