Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEditorEngine } from '@/components/store/editor';
import type { FrameData } from '@/components/store/editor/frames';
import { getRelativeMousePositionToFrameView } from '@/components/store/editor/overlay/utils';
import type { DomElement, ElementPosition, WebFrame } from '@onlook/models';
import type { DomElement, ElementPosition, Frame } from '@onlook/models';
import { EditorMode, MouseAction } from '@onlook/models';
import { toast } from '@onlook/ui/sonner';
import { cn } from '@onlook/ui/utils';
Expand All @@ -10,7 +10,7 @@ import { observer } from 'mobx-react-lite';
import { useCallback, useEffect, useMemo } from 'react';
import { RightClickMenu } from './right-click';

export const GestureScreen = observer(({ frame, isResizing }: { frame: WebFrame, isResizing: boolean }) => {
export const GestureScreen = observer(({ frame, isResizing }: { frame: Frame, isResizing: boolean }) => {
const editorEngine = useEditorEngine();

const getFrameData: () => FrameData | undefined = useCallback(() => {
Expand Down Expand Up @@ -94,19 +94,19 @@ export const GestureScreen = observer(({ frame, isResizing }: { frame: WebFrame,
() =>
throttle(async (e: React.MouseEvent<HTMLDivElement>) => {

if (editorEngine.move.shouldDrag) {
await editorEngine.move.drag(e, getRelativeMousePosition);
} else if (
editorEngine.state.editorMode === EditorMode.DESIGN ||
((editorEngine.state.editorMode === EditorMode.INSERT_DIV ||
editorEngine.state.editorMode === EditorMode.INSERT_TEXT ||
editorEngine.state.editorMode === EditorMode.INSERT_IMAGE) &&
!editorEngine.insert.isDrawing)
) {
await handleMouseEvent(e, MouseAction.MOVE);
} else if (editorEngine.insert.isDrawing) {
editorEngine.insert.draw(e);
}
if (editorEngine.move.shouldDrag) {
await editorEngine.move.drag(e, getRelativeMousePosition);
} else if (
editorEngine.state.editorMode === EditorMode.DESIGN ||
((editorEngine.state.editorMode === EditorMode.INSERT_DIV ||
editorEngine.state.editorMode === EditorMode.INSERT_TEXT ||
editorEngine.state.editorMode === EditorMode.INSERT_IMAGE) &&
!editorEngine.insert.isDrawing)
) {
await handleMouseEvent(e, MouseAction.MOVE);
} else if (editorEngine.insert.isDrawing) {
editorEngine.insert.draw(e);
}
}, 16),
[editorEngine, getRelativeMousePosition, handleMouseEvent],
);
Expand Down Expand Up @@ -148,9 +148,9 @@ export const GestureScreen = observer(({ frame, isResizing }: { frame: WebFrame,
if (!frameData) {
return;
}

editorEngine.move.cancelDragPreparation();

await editorEngine.move.end(e);
await editorEngine.insert.end(e, frameData.view);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { FrameType, type Frame, type WebFrame } from '@onlook/models';
import { type Frame } from '@onlook/models';
import { observer } from 'mobx-react-lite';
import { useRef, useState } from 'react';
import { useState } from 'react';
import { GestureScreen } from './gesture';
import { ResizeHandles } from './resize-handles';
import { RightClickMenu } from './right-click';
import { TopBar } from './top-bar';
import { WebFrameComponent, type WebFrameView } from './web-frame';
import { FrameComponent } from './view';

export const FrameView = observer(({ frame }: { frame: Frame }) => {
const webFrameRef = useRef<WebFrameView>(null);
const [isResizing, setIsResizing] = useState(false);

return (
Expand All @@ -17,14 +16,12 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => {
style={{ transform: `translate(${frame.position.x}px, ${frame.position.y}px)` }}
>
<RightClickMenu>
<TopBar frame={frame as WebFrame} />
<TopBar frame={frame} />
</RightClickMenu>
<div className="relative">
<ResizeHandles frame={frame} setIsResizing={setIsResizing} />
{frame.type === FrameType.WEB && (
<WebFrameComponent frame={frame as WebFrame} ref={webFrameRef} />
)}
<GestureScreen frame={frame as WebFrame} isResizing={isResizing} />
<FrameComponent frame={frame} />
<GestureScreen frame={frame} isResizing={isResizing} />
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEditorEngine } from '@/components/store/editor';
import { LeftPanelTabValue, type PageNode, type WebFrame } from '@onlook/models';
import { LeftPanelTabValue, type Frame, type PageNode } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import {
DropdownMenu,
Expand All @@ -16,7 +16,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { PageModal } from '../../left-panel/page-tab/page-modal';

interface PageSelectorProps {
frame: WebFrame;
frame: Frame;
className?: string;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEditorEngine } from '@/components/store/editor';
import type { WebFrame } from '@onlook/models';
import type { Frame } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import { Icons } from '@onlook/ui/icons';
import { cn } from '@onlook/ui/utils';
Expand All @@ -10,7 +10,7 @@ import { HoverOnlyTooltip } from '../../editor-bar/hover-tooltip';
import { PageSelector } from './page-selector';

export const TopBar = observer(
({ frame }: { frame: WebFrame }) => {
({ frame }: { frame: Frame }) => {
const editorEngine = useEditorEngine();
const isSelected = editorEngine.frames.isSelected(frame.id);
const topBarRef = useRef<HTMLDivElement>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEditorEngine } from '@/components/store/editor';
import type { WebFrame } from '@onlook/models';
import type { Frame } from '@onlook/models';
import {
PENPAL_PARENT_CHANNEL,
type PenpalChildMethods,
Expand All @@ -22,19 +22,19 @@ import {
type IframeHTMLAttributes,
} from 'react';

export type WebFrameView = HTMLIFrameElement & {
export type FrameView = HTMLIFrameElement & {
setZoomLevel: (level: number) => void;
supportsOpenDevTools: () => boolean;
reload: () => void;
isLoading: () => boolean;
} & PromisifiedPendpalChildMethods;

interface WebFrameViewProps extends IframeHTMLAttributes<HTMLIFrameElement> {
frame: WebFrame;
interface FrameViewProps extends IframeHTMLAttributes<HTMLIFrameElement> {
frame: Frame;
}

export const WebFrameComponent = observer(
forwardRef<WebFrameView, WebFrameViewProps>(({ frame, ...props }, ref) => {
export const FrameComponent = observer(
forwardRef<FrameView, FrameViewProps>(({ frame, ...props }, ref) => {
const editorEngine = useEditorEngine();
const iframeRef = useRef<HTMLIFrameElement>(null);
const zoomLevel = useRef(1);
Expand Down Expand Up @@ -229,11 +229,11 @@ export const WebFrameComponent = observer(
};
}, [penpalChild]);

useImperativeHandle(ref, (): WebFrameView => {
useImperativeHandle(ref, (): FrameView => {
const iframe = iframeRef.current;
if (!iframe) {
console.error(`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Iframe - Not found`);
return {} as WebFrameView;
return {} as FrameView;
}

const syncMethods = {
Expand All @@ -252,11 +252,11 @@ export const WebFrameComponent = observer(
console.warn(
`${PENPAL_PARENT_CHANNEL} (${frame.id}) - Failed to setup penpal connection: iframeRemote is null`,
);
return Object.assign(iframe, syncMethods, remoteMethods) as WebFrameView;
return Object.assign(iframe, syncMethods, remoteMethods) as FrameView;
}

// Register the iframe with the editor engine
editorEngine.frames.registerView(frame, iframe as WebFrameView);
editorEngine.frames.registerView(frame, iframe as FrameView);

return Object.assign(iframe, {
...syncMethods,
Expand Down
14 changes: 11 additions & 3 deletions apps/web/client/src/app/project/[id]/_hooks/use-start-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const useStartProject = () => {
const apiUtils = api.useUtils();
const { data: user, isLoading: isUserLoading, error: userError } = api.user.get.useQuery();
const { data: project, isLoading: isProjectLoading, error: projectError } = api.project.get.useQuery({ projectId: editorEngine.projectId });
const { data: branch, isLoading: isBranchLoading, error: branchError } = api.branch.getByProjectId.useQuery({ projectId: editorEngine.projectId });
const { data: canvasWithFrames, isLoading: isCanvasLoading, error: canvasError } = api.userCanvas.getWithFrames.useQuery({ projectId: editorEngine.projectId });
const { data: conversations, isLoading: isConversationsLoading, error: conversationsError } = api.chat.conversation.getAll.useQuery({ projectId: editorEngine.projectId });
const { data: creationRequest, isLoading: isCreationRequestLoading, error: creationRequestError } = api.project.createRequest.getPendingRequest.useQuery({ projectId: editorEngine.projectId });
Expand All @@ -40,10 +41,16 @@ export const useStartProject = () => {
useEffect(() => {
if (project) {
startSandbox(project);
editorEngine.screenshot.lastScreenshotAt = project.metadata.updatedPreviewImgAt;
editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt ?? null;
}
Comment on lines +42 to 43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Confirm lastScreenshotAt type; updatedAt may be Date/string vs previous numeric timestamp

The old field (updatedPreviewImgAt) was likely a number (epoch ms). The new chain uses previewImg?.updatedAt, which may be a string/Date. If consumers of editorEngine.screenshot.lastScreenshotAt expect a number, this will silently break comparisons/sorts.

Run this to verify both types before we normalize:

If lastScreenshotAt should remain a number, normalize:

-            editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt ?? null;
+            editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt
+                ? new Date(project.metadata.previewImg.updatedAt as unknown as string | number | Date).getTime()
+                : null;

If it's allowed to be a string/Date, consider renaming the field or adding a JSDoc to prevent type drift.


🏁 Script executed:

#!/bin/bash
# Where is lastScreenshotAt defined/used?
rg -nP -C3 "\blastScreenshotAt\b" apps packages -g '!**/node_modules/**'
# Inspect the type of Project.metadata.previewImg.updatedAt
rg -nP -C3 "previewImg\b" packages -g '!**/node_modules/**'

Length of output: 8912


Fix mismatched types for lastScreenshotAt

The screenshot.lastScreenshotAt setter is typed as Date | null, but project.metadata.previewImg?.updatedAt is a string (per packages/models/src/project/project.ts), so assigning it directly will store a string instead of a Date.

• In apps/web/client/src/app/project/[id]/_hooks/use-start-project.tsx (line 44)
• Normalize the string to a Date:

- editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt ?? null;
+ editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt
+     ? new Date(project.metadata.previewImg.updatedAt)
+     : null;

Consider adding a JSDoc or strengthening the type for previewImg.updatedAt if you ever intend to accept other formats.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt ?? null;
}
editorEngine.screenshot.lastScreenshotAt = project.metadata.previewImg?.updatedAt
? new Date(project.metadata.previewImg.updatedAt)
: null;
}
🤖 Prompt for AI Agents
In apps/web/client/src/app/project/[id]/_hooks/use-start-project.tsx around
lines 44-45, the setter editorEngine.screenshot.lastScreenshotAt is typed Date |
null but project.metadata.previewImg?.updatedAt is a string, so
convert/normalize that string to a Date (or null) before assignment: if
previewImg?.updatedAt exists, construct a Date from it and check it’s valid (not
NaN) then assign that Date, otherwise assign null; consider adding a short
inline comment/JSDoc noting the normalization and optionally strengthen the
previewImg.updatedAt type to Date in models if you accept non-string values.

}, [project]);

useEffect(() => {
if (branch) {
editorEngine.branches.switchToBranch(branch.id);
}
}, [branch]);

const startSandbox = async (project: Project) => {
try {
await editorEngine.sandbox.session.start(project.sandbox.id);
Expand Down Expand Up @@ -129,10 +136,11 @@ export const useStartProject = () => {
!isCanvasLoading &&
!isConversationsLoading &&
!isCreationRequestLoading &&
!isSandboxLoading;
!isSandboxLoading &&
!isBranchLoading;

setIsProjectReady(allQueriesResolved);
}, [isUserLoading, isProjectLoading, isCanvasLoading, isConversationsLoading, isCreationRequestLoading, isSandboxLoading]);
}, [isUserLoading, isProjectLoading, isCanvasLoading, isConversationsLoading, isCreationRequestLoading, isSandboxLoading, isBranchLoading]);

useEffect(() => {
setError(userError?.message ?? projectError?.message ?? canvasError?.message ?? conversationsError?.message ?? creationRequestError?.message ?? null);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/client/src/app/projects/_components/select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ export const SelectProject = ({ externalSearchQuery }: { externalSearchQuery?: s
const [spacing] = useState<number>(24);

// Templates
const projects = fetchedProjects?.filter(project => !project.tags?.includes(Tags.TEMPLATE)) ?? [];
const templateProjects = fetchedProjects?.filter(project => project.tags?.includes(Tags.TEMPLATE)) ?? [];
const projects = fetchedProjects?.filter(project => !project.metadata.tags.includes(Tags.TEMPLATE)) ?? [];
const templateProjects = fetchedProjects?.filter(project => project.metadata.tags.includes(Tags.TEMPLATE)) ?? [];
const shouldShowTemplate = templateProjects.length > 0;
const [selectedTemplate, setSelectedTemplate] = useState<Project | null>(null);
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/client/src/app/projects/_components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function Settings({ project, refetch }: { project: Project; refetch: () =
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [projectName, setProjectName] = useState(project.name);
const isProjectNameEmpty = useMemo(() => projectName.length === 0, [projectName]);
const isTemplate = project.tags?.includes(Tags.TEMPLATE) || false;
const isTemplate = project.metadata.tags.includes(Tags.TEMPLATE) || false;

useEffect(() => {
setProjectName(project.name);
Expand Down
137 changes: 137 additions & 0 deletions apps/web/client/src/components/store/editor/branch/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Branch } from '@onlook/models';
import { makeAutoObservable } from 'mobx';
import type { EditorEngine } from '../engine';
import { SandboxManager } from '../sandbox';

export class BranchManager {
private editorEngine: EditorEngine;
private currentBranchId: string | null = null;
private branchIdToSandboxManager = new Map<string, SandboxManager>();

constructor(editorEngine: EditorEngine) {
this.editorEngine = editorEngine;
makeAutoObservable(this);
}

get currentBranch(): string | null {
return this.currentBranchId;
}

getCurrentSandbox(): SandboxManager {
if (!this.currentBranchId) {
throw new Error('No branch selected. Call switchToBranch() first.');
}

if (!this.branchIdToSandboxManager.has(this.currentBranchId)) {
const sandboxManager = new SandboxManager(this.editorEngine);
this.branchIdToSandboxManager.set(this.currentBranchId, sandboxManager);
}

return this.branchIdToSandboxManager.get(this.currentBranchId)!;
}

async startCurrentBranchSandbox(): Promise<void> {
if (!this.currentBranchId) {
throw new Error('No branch selected. Call switchToBranch() first.');
}

const branch = await this.getBranchById(this.currentBranchId);
await this.getCurrentSandbox().session.start(branch.sandbox.id);
}

async switchToBranch(branchId: string): Promise<void> {
if (this.currentBranchId === branchId) {
return;
}

this.currentBranchId = branchId;
}

async createBranch(
name: string,
description?: string,
fromBranchId?: string,
isDefault = false
): Promise<Branch> {
const newBranch: Branch = {
id: `branch-${Date.now()}`,
name,
description: description || null,
createdAt: new Date(),
updatedAt: new Date(),
git: null,
sandbox: {
id: `sandbox-${Date.now()}`,
},
};

return newBranch;
}

async deleteBranch(branchId: string): Promise<void> {
if (branchId === this.currentBranchId) {
throw new Error('Cannot delete the currently active branch');
}

const sandboxManager = this.branchIdToSandboxManager.get(branchId);
if (sandboxManager) {
sandboxManager.clear();
this.branchIdToSandboxManager.delete(branchId);
}
}

async getDefaultBranch(): Promise<Branch> {
return {
id: 'main-branch-id',
name: 'main',
description: 'Default main branch',
createdAt: new Date(),
updatedAt: new Date(),
git: null,
sandbox: {
id: 'main-sandbox-id',
},
};
}

async getBranchById(branchId: string): Promise<Branch> {
return {
id: branchId,
name: branchId === 'main-branch-id' ? 'main' : `branch-${branchId}`,
description: null,
createdAt: new Date(),
updatedAt: new Date(),
git: null,
sandbox: {
id: `${branchId}-sandbox`,
},
};
}


private async createMainBranch(): Promise<Branch> {
return {
id: 'main-branch-id',
name: 'main',
description: 'Default main branch',
createdAt: new Date(),
updatedAt: new Date(),
git: null,
sandbox: {
id: 'main-sandbox-id',
},
};
}

async listBranches(): Promise<Branch[]> {
return [];
}

clear(): void {
for (const sandboxManager of this.branchIdToSandboxManager.values()) {
sandboxManager.clear();
}
this.branchIdToSandboxManager.clear();
this.currentBranchId = null;
}
}
Loading