Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const OverlayChatInput = observer(({
return (
<div
className={cn(
'rounded-xl backdrop-blur-lg transition-all duration-300',
'rounded-xl backdrop-blur-lg transition-all duration-150',
'shadow-xl shadow-background-secondary/50',
inputState.isInputting
? 'bg-background/80 border shadow-xl shadow-background-secondary/50 p-1'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ export const OverlayButtons = observer(() => {
}, [chatPosition.x, chatPosition.y]);

const animationClass =
'origin-center opacity-0 -translate-y-2 transition-all duration-200';
'origin-center opacity-0 -translate-y-1 transition-all duration-100';

useEffect(() => {
if (domId) {
requestAnimationFrame(() => {
const element = document.querySelector(`[data-element-id="${domId}"]`);
if (element) {
element.classList.remove('scale-[0.2]', 'opacity-0', '-translate-y-2');
element.classList.remove('scale-[0.2]', 'opacity-0', '-translate-y-1');
element.classList.add('scale-100', 'opacity-100', 'translate-y-0');
}
});
Expand All @@ -72,7 +72,7 @@ export const OverlayButtons = observer(() => {
transform: 'translate(-50%, 0)',
transformOrigin: 'center center',
pointerEvents: 'auto',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transition: 'all 0.15s ease-out',
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const Overlay = observer(() => {
id={EditorAttributes.OVERLAY_CONTAINER_ID}
className={cn(
'absolute top-0 left-0 h-0 w-0 pointer-events-none',
editorEngine.state.shouldHideOverlay ? 'opacity-0' : 'opacity-100 transition-opacity duration-150',
editorEngine.state.shouldHideOverlay ? 'opacity-0' : 'opacity-100 transition-opacity duration-75',
editorEngine.state.editorMode === EditorMode.PREVIEW && 'hidden',
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,16 @@ export const EditorBar = observer(({ availableWidth }: { availableWidth?: number
return (
<DropdownManagerProvider>
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
exit={{ opacity: 0, y: 8 }}
className={cn(
'flex flex-col border-[0.5px] border-border p-1 px-1 bg-background rounded-xl backdrop-blur drop-shadow-xl z-50 overflow-hidden',
editorEngine.state.editorMode === EditorMode.PREVIEW && !windowSelected && 'hidden',
)}
transition={{
type: 'spring',
bounce: 0.1,
duration: 0.4,
stiffness: 200,
damping: 25,
duration: 0.15,
ease: 'easeOut',
}}
>
{getTopBar()}
Expand Down
29 changes: 27 additions & 2 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 @@ -126,9 +126,34 @@ export const useStartProject = () => {

useEffect(() => {
if (tabState === 'reactivated') {
editorEngine.activeSandbox.session.reconnect(editorEngine.projectId, user?.id);
// Warm up the connection and clear throttled state
const warmUpConnection = async () => {
// Reconnect sandbox session
await editorEngine.activeSandbox.session.reconnect(editorEngine.projectId, user?.id);

// Force a refresh of all frames to re-establish penpal connections
editorEngine.frames.getAll().forEach((frameData) => {
if (frameData.view) {
// Trigger a lightweight operation to wake up the connection
frameData.view.getFrameId?.().catch(() => {});
}
});

// Clear any stale overlay state and refresh
editorEngine.overlay.clearUI();
if (editorEngine.elements.selected.length > 0) {
// Re-trigger selection to refresh the outline
const selected = [...editorEngine.elements.selected];
editorEngine.elements.click(selected);
}

// Invalidate cached data to ensure fresh state
await apiUtils.invalidate();
};

warmUpConnection().catch(console.error);
}
}, [tabState]);
}, [tabState, user?.id, editorEngine, apiUtils]);

useEffect(() => {
setError(userError?.message ?? canvasError?.message ?? conversationsError?.message ?? creationRequestError?.message ?? null);
Expand Down
69 changes: 63 additions & 6 deletions apps/web/client/src/components/store/editor/chat/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ import {
type ProjectMessageContext,
} from '@onlook/models/chat';
import type { ParsedError } from '@onlook/utility';
import { debounce } from 'lodash';
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused import bug: The debounce import from lodash is added but never used in the code. This creates dead code and potential bundle bloat. The debouncing logic uses requestAnimationFrame instead. Remove this unused import.

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

import { makeAutoObservable, reaction } from 'mobx';
import type { EditorEngine } from '../engine';

export class ChatContext {
context: MessageContext[] = [];
private selectedReactionDisposer?: () => void;
private pendingContextUpdate?: number;
private contextUpdateAbortController?: AbortController;

constructor(
private editorEngine: EditorEngine,
) {
Expand All @@ -25,7 +29,37 @@ export class ChatContext {
init() {
this.selectedReactionDisposer = reaction(
() => this.editorEngine.elements.selected,
() => this.getChatContext().then((context) => (this.context = context)),
() => {
// Cancel any pending context update
if (this.pendingContextUpdate) {
cancelAnimationFrame(this.pendingContextUpdate);
}

// Abort any in-flight async operations
if (this.contextUpdateAbortController) {
this.contextUpdateAbortController.abort();
}

// Create new abort controller for this update
this.contextUpdateAbortController = new AbortController();
const signal = this.contextUpdateAbortController.signal;

// Defer context update to prevent blocking UI
this.pendingContextUpdate = requestAnimationFrame(() => {
if (!signal.aborted) {
this.getChatContext().then((context) => {
if (!signal.aborted) {
this.context = context;
}
}).catch((error) => {
if (!signal.aborted) {
console.error('Error updating chat context:', error);
}
});
}
this.pendingContextUpdate = undefined;
});
},
);
}

Expand Down Expand Up @@ -85,17 +119,28 @@ export class ChatContext {
highlightedContext.forEach(highlight => {
filePathToBranch.set(highlight.path, highlight.branchId);
});

// Limit the number of file reads to prevent memory issues
const MAX_FILES_TO_READ = 5;
const filesToRead = Array.from(filePathToBranch.entries()).slice(0, MAX_FILES_TO_READ);

for (const [filePath, branchId] of filePathToBranch) {
for (const [filePath, branchId] of filesToRead) {
const file = await this.editorEngine.activeSandbox.readFile(filePath);
if (file === null || file.type === 'binary') {
continue;
}

// Limit file content size to prevent huge memory usage
const MAX_CONTENT_LENGTH = 50000; // ~50KB per file
const truncatedContent = file.content.length > MAX_CONTENT_LENGTH
? file.content.substring(0, MAX_CONTENT_LENGTH) + '\n... [truncated]'
: file.content;

fileContext.push({
type: MessageContextType.FILE,
displayName: filePath,
path: filePath,
content: file.content,
content: truncatedContent,
branchId: branchId,
});
}
Expand Down Expand Up @@ -133,19 +178,19 @@ export class ChatContext {
for (const node of selected) {
const oid = node.oid;
if (!oid) {
console.error('No oid found for node', node);
// Removed console.error to prevent log spam
continue;
}

const codeBlock = await this.editorEngine.templateNodes.getCodeBlock(oid);
if (codeBlock === null) {
console.error('No code block found for node', node);
// Removed console.error to prevent log spam
continue;
}

const templateNode = this.editorEngine.templateNodes.getTemplateNode(oid);
if (!templateNode) {
console.error('No template node found for node', node);
// Removed console.error to prevent log spam
continue;
}

Expand Down Expand Up @@ -283,6 +328,18 @@ export class ChatContext {
}

clear() {
// Cancel pending updates
if (this.pendingContextUpdate) {
cancelAnimationFrame(this.pendingContextUpdate);
this.pendingContextUpdate = undefined;
}

// Abort in-flight operations
if (this.contextUpdateAbortController) {
this.contextUpdateAbortController.abort();
this.contextUpdateAbortController = undefined;
}

this.selectedReactionDisposer?.();
this.selectedReactionDisposer = undefined;
this.context = [];
Expand Down
72 changes: 49 additions & 23 deletions apps/web/client/src/components/store/editor/element/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { CoreElementType, DomElement, DynamicType } from '@onlook/models';
import type { RemoveElementAction } from '@onlook/models/actions';
import { toast } from '@onlook/ui/sonner';
import { makeAutoObservable } from 'mobx';
import { makeAutoObservable, runInAction } from 'mobx';
import type { EditorEngine } from '../engine';
import type { FrameData } from '../frames';
import { adaptRectToCanvas } from '../overlay/utils';

export class ElementsManager {
private _hovered: DomElement | undefined;
private _selected: DomElement[] = [];
private lastClickTime: number = 0;

constructor(private editorEngine: EditorEngine) {
makeAutoObservable(this);
Expand Down Expand Up @@ -60,30 +61,55 @@ export class ElementsManager {
}

click(domEls: DomElement[]) {
this.editorEngine.overlay.state.removeClickRects();
this.clearSelectedElements();

for (const domEl of domEls) {
const frameData = this.editorEngine.frames.get(domEl.frameId);
if (!frameData) {
console.error('Frame data not found');
continue;
}
const { view } = frameData;
if (!view) {
console.error('No frame view found');
continue;
const now = performance.now();
const timeSinceLastClick = now - this.lastClickTime;
this.lastClickTime = now;

// If it's been more than 5 seconds since last click, we may need to warm up
const needsWarmup = timeSinceLastClick > 5000;

// Batch all synchronous updates for the outline rendering
runInAction(() => {
// Immediately update the visual outline for instant feedback
this.editorEngine.overlay.state.removeClickRects();

// Process outline rendering first for immediate visual feedback
for (const domEl of domEls) {
const frameData = this.editorEngine.frames.get(domEl.frameId);
if (!frameData) {
console.error('Frame data not found');
continue;
}
const { view } = frameData;
if (!view) {
console.error('No frame view found');
continue;
}
const adjustedRect = adaptRectToCanvas(domEl.rect, view);
const isComponent = !!domEl.instanceId;
this.editorEngine.overlay.state.addClickRect(
adjustedRect,
domEl.styles,
isComponent,
domEl.domId,
);
}
const adjustedRect = adaptRectToCanvas(domEl.rect, view);
const isComponent = !!domEl.instanceId;
this.editorEngine.overlay.state.addClickRect(
adjustedRect,
domEl.styles,
isComponent,
domEl.domId,
);
this._selected.push(domEl);
});

// If we need warmup, do a quick no-op to wake up the engine
if (needsWarmup && domEls.length > 0) {
// Quick ping to the frame to ensure connection is active
const frameData = this.editorEngine.frames.get(domEls[0].frameId);
frameData?.view?.getFrameId?.().catch(() => {});
}

// Update selected elements in a separate action to control reaction timing
runInAction(() => {
this.clearSelectedElements();
for (const domEl of domEls) {
this._selected.push(domEl);
}
});
}

setHoveredElement(element: DomElement) {
Expand Down
Loading