Skip to content

Conversation

reddygtvs
Copy link
Contributor

@reddygtvs reddygtvs commented Aug 22, 2025

Description

  • Screenshots now target the most recently interacted frame
  • Added screenshot preview functionality with smooth transitions
  • Fixed context array issues with duplicate screenshot IDs

Related Issues

related to #2676

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Release
  • Refactor
  • Other (please describe):

Testing

I switched from Firecrawl to DOM screenshotting for frame-specific screenshots because Firecrawl can't target individual iframes, only entire pages. Since we needed screenshots of the specific frame the user was interacting with (editorEngine.frames.getMostRecentlyInteractedFrame()), Firecrawl wasn't able to target it.

The DOM-to-canvas approach targets exactly the frame we want. The downside is rendering quality: fonts, spacing, and styling don't translate perfectly to canvas. Better solutions like Playwright need extra dependencies and risky permissions. I prioritized frame targeting and security over perfect visuals.

I also fixed a bug where multiple identical screenshots would break the context array by getting the same base64 ID. Added smooth preview transitions because Firecrawl screenshots aren't perfect and I couldn't tell what they were capturing without previewing.

Firecrawl still handles project thumbnails. DOM approach works for chat screenshots.

Screenshots (if applicable)

Additional Notes

P.S. Apologies on the git diffs, I had to rebase 3 different times today as there were a lot of pushes to the repo :)
Although I was careful, this ended up being a much larger change than I initially expected, so I will keep iterating on it if anyone notices something wrong.


Important

Introduces frame-specific screenshots and preview functionality, fixes duplicate screenshot ID issues, and updates frame interaction tracking.

  • Behavior:
    • Screenshots now target the most recently interacted frame using getMostRecentlyInteractedFrame() in FramesManager.
    • Added screenshot preview functionality with smooth transitions in DraftImagePill.
    • Fixed context array issues with duplicate screenshot IDs in ChatContext.
  • Functions:
    • captureScreenshot() in screenshot.ts now uses DOM-to-canvas rendering instead of Firecrawl.
    • addImageContext() added to ChatContext to handle image contexts.
  • Classes:
    • FramesManager tracks frame interactions with updateLastInteraction() and getMostRecentlyInteractedFrame().
    • ElementsManager updates frame interaction on element click.
  • Misc:
    • Minor changes in chat-input/index.tsx and input-context-pills.tsx to integrate new screenshot and preview features.

This description was created by Ellipsis for d6208c3. You can customize this summary. It will automatically update as commits are pushed.

Summary by CodeRabbit

  • New Features

    • Click an image attachment pill to open a full-size preview modal.
    • Screenshots now capture the currently active frame for quicker, targeted sharing.
  • Bug Fixes

    • More robust error handling and toasts when screenshots fail; prevents incomplete attachments.
    • Stabilized rendering of image attachment pills to reduce flicker.
  • Refactor

    • Improved screenshot pipeline with graceful fallback image when capture isn’t possible.
    • Interaction-aware frame handling enhances consistency across selection, navigation, and capture flows.

Copy link

vercel bot commented Aug 22, 2025

@reddygtvs is attempting to deploy a commit to the Onlook Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

coderabbitai bot commented Aug 22, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Reworks screenshot capture to use the most recently interacted frame; adds interaction tracking across frames (selection, navigation, element clicks); introduces a portal-based image preview for draft image pills; adds ChatContext.addImageContext; overhauls preload screenshot API to DOM-to-canvas with compression and fallback; minor key prop tweak; one formatting-only change.

Changes

Cohort / File(s) Summary of Changes
Chat input screenshot targeting
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx
Capture now targets frames.getMostRecentlyInteractedFrame(); single-frame capture with updated error handling; sets image context and toasts on success/failure.
Draft image preview portal
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx
Adds portal-based modal preview on pill click; measures pill position; SSR-safe portal; retains remove behavior; internal ref/state added.
Input pills key change
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/input-context-pills.tsx
Changes key for image pills from content-based to index-based (image-${index}).
Chat context API
apps/web/client/src/components/store/editor/chat/context.ts
Adds addImageContext(imageData); replaces existing IMAGE contexts with a new one using current time and image/jpeg.
Element interaction tracking
apps/web/client/src/components/store/editor/element/index.ts
Logs errors with frameId; calls frames.updateLastInteraction on valid element clicks before overlay logic.
Frames manager interaction model
apps/web/client/src/components/store/editor/frames/manager.ts
Adds lastInteractionTime to FrameData; integrates updateLastInteraction in select/navigation; preserves field on updates/register; adds getMostRecentlyInteractedFrame.
Screenshot manager formatting
apps/web/client/src/components/store/editor/screenshot/index.tsx
Formatting-only (blank line); no functional changes.
Preload screenshot pipeline
apps/web/preload/script/api/screenshot.ts
Replaces getDisplayMedia with DOM-to-canvas render plus compression; adds robust size-bounded compression, constants, and fallback canvas with error message JPEG.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant ChatInput
  participant Frames as FramesManager
  participant FrameView
  participant ChatCtx as ChatContext
  participant UI as UI/Toast

  User->>ChatInput: Click "Screenshot"
  ChatInput->>Frames: getMostRecentlyInteractedFrame()
  alt Frame available
    ChatInput->>FrameView: captureScreenshot()
    alt Capture success with data
      ChatInput->>ChatCtx: addImageContext(base64 JPEG)
      ChatCtx-->>ChatInput: Context updated
      ChatInput->>UI: Show success toast
    else Failure or no data
      ChatInput->>UI: Show error toast
    end
  else No frame
    ChatInput->>UI: Show "No active frame" error
  end
Loading
sequenceDiagram
  autonumber
  actor User
  participant Element as ElementStore
  participant Frames as FramesManager

  User->>Element: Click in frame
  Element->>Frames: updateLastInteraction(frameId)
  note right of Frames: Sets lastInteractionTime = now
  Element-->>User: Proceed with overlay/select logic

  User->>Frames: select(frames)
  Frames->>Frames: updateLastInteraction(each frameId)

  User->>Frames: navigateToPath(frameId, path)
  Frames->>Frames: updateLastInteraction(frameId)
Loading
sequenceDiagram
  autonumber
  participant API as captureScreenshot
  participant Impl as captureScreenshotImpl
  participant Render as renderDomToCanvas
  participant Compress as compressImage

  API->>Impl: Invoke
  Impl->>Render: Render DOM to canvas
  Render-->>Impl: Canvas
  Impl->>Compress: Scale/quality iteration (<=5MB)
  alt Fits limit
    Compress-->>Impl: base64 JPEG
    Impl-->>API: base64 JPEG
  else Render/compress fails
    Impl-->>API: Fallback canvas JPEG with message
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • Kitenite

Poem

I tapped a frame—click, click—oh my!
A canvas bloomed beneath the sky.
JPEG whispers, small and bright,
Hopping through the chat by night.
One frame chosen, neat and keen—
A bunny bows to the screenshot scene. 🐇🖼️

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f2256ec and 8b6a829.

📒 Files selected for processing (1)
  • apps/web/client/src/components/store/editor/screenshot/index.tsx (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • apps/web/client/src/components/store/editor/screenshot/index.tsx
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/preload/script/api/screenshot.ts (2)

77-104: Fix 5MB cap check — you’re comparing base64 characters to bytes.

Base64 length must be converted back to bytes before comparing to MAX_FILE_SIZE. As-is, you’ll compress far more than necessary and degrade quality.

Apply this diff:

-    const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit
-    const MAX_BASE64_SIZE = MAX_FILE_SIZE * 0.75;
+    const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit (bytes)
+    const approxBytes = (dataUrl: string) => {
+        // Strip "data:*;base64," prefix if present, then convert Base64 chars to bytes
+        const comma = dataUrl.indexOf(',');
+        const base64Part = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
+        return Math.floor(base64Part.length * 0.75);
+    };

And update the acceptance check:

-            const base64 = scaledCanvas.toDataURL('image/jpeg', quality);
-
-            if (base64.length <= MAX_BASE64_SIZE) {
+            const base64 = scaledCanvas.toDataURL('image/jpeg', quality);
+            if (approxBytes(base64) <= MAX_FILE_SIZE) {
                 return base64;
             }

222-233: Avoid tainting the canvas on cross-origin images without CORS.

Drawing a cross-origin image without proper CORS headers taints the canvas and makes toDataURL throw, forcing your global “unavailable” fallback. Detect and render a placeholder instead so the rest of the DOM still exports.

-    if (element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0) {
+    if (element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0) {
         try {
-            context.drawImage(element, left, top, width, height);
+            // Avoid cross-origin taint if the image lacks CORS
+            const src = element.currentSrc || element.src || '';
+            let crossOriginTaintRisk = false;
+            try {
+                const url = new URL(src, document.baseURI);
+                crossOriginTaintRisk = url.origin !== window.location.origin && !element.crossOrigin;
+            } catch {
+                crossOriginTaintRisk = false;
+            }
+            if (crossOriginTaintRisk) {
+                throw new Error('cross-origin image without CORS');
+            }
+            context.drawImage(element, left, top, width, height);
         } catch (error) {
             context.fillStyle = '#f0f0f0';
             context.fillRect(left, top, width, height);
             context.fillStyle = '#999999';
             context.font = '12px Arial, sans-serif';
             context.textAlign = 'center';
+            context.textBaseline = 'middle';
             context.fillText('Image', left + width / 2, top + height / 2);
         }
     }
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (1)

219-224: Replace “limit reached” error with “replace existing image” behavior

The PR introduces ChatContext.addImageContext(imageData) to replace any existing IMAGE context with the latest one. Here we still enforce a hard limit via validateImageLimit(..., 1) and push, which blocks taking a second screenshot. This diverges from the intended UX and can frustrate users.

Apply either Option A (use the new API) or Option B (local replace) to align behavior:

Option A — delegate to context (preferred if available):

-            const { success, errorMessage } = validateImageLimit(currentImages, 1);
-            if (!success) {
-                toast.error(errorMessage);
-                return;
-            }
+            // No hard limit; let context replace any existing IMAGE
@@
-            const contextImage: ImageMessageContext = {
-                type: MessageContextType.IMAGE,
-                content: screenshotData,
-                mimeType: mimeType,
-                displayName: 'Screenshot',
-            };
-            editorEngine.chat.context.context.push(contextImage);
+            // Prefer centralized helper that replaces existing IMAGE contexts
+            if (typeof (editorEngine.chat.context as any).addImageContext === 'function') {
+                (editorEngine.chat.context as any).addImageContext(screenshotData);
+            } else {
+                // Fallback to local replace if helper isn’t available at runtime
+                const imageIndex = editorEngine.chat.context.context.findIndex(
+                    (ctx) => ctx.type === MessageContextType.IMAGE
+                );
+                const contextImage: ImageMessageContext = {
+                    type: MessageContextType.IMAGE,
+                    content: screenshotData,
+                    mimeType,
+                    displayName: 'Screenshot',
+                };
+                if (imageIndex >= 0) {
+                    editorEngine.chat.context.context[imageIndex] = contextImage;
+                } else {
+                    editorEngine.chat.context.context.push(contextImage);
+                }
+            }

Option B — local replace only (if you prefer not to rely on the helper here):

-            const { success, errorMessage } = validateImageLimit(currentImages, 1);
-            if (!success) {
-                toast.error(errorMessage);
-                return;
-            }
+            // Allow replacing the existing IMAGE with a fresh screenshot
@@
-            editorEngine.chat.context.context.push(contextImage);
+            const imageIndex = editorEngine.chat.context.context.findIndex(
+                (ctx) => ctx.type === MessageContextType.IMAGE
+            );
+            if (imageIndex >= 0) {
+                editorEngine.chat.context.context[imageIndex] = contextImage;
+            } else {
+                editorEngine.chat.context.context.push(contextImage);
+            }

Also applies to: 255-262

🧹 Nitpick comments (13)
apps/web/client/src/components/store/editor/chat/context.ts (1)

222-230: Derive mimeType from the data URL and normalize content to avoid metadata drift.

Hardcoding 'image/jpeg' risks desync if the capture pipeline ever changes (e.g., to WebP/PNG) or if callers pass a data URL with a different MIME. Normalize incoming data to a data URL and infer the MIME so content and mimeType stay consistent.

Apply this diff:

-    addImageContext(imageData: string) {
-        const imageContext: ImageMessageContext = {
-            type: MessageContextType.IMAGE,
-            content: imageData,
-            displayName: `Screenshot ${new Date().toLocaleTimeString()}`,
-            mimeType: 'image/jpeg',
-        };
-        this.context = [...this.context.filter(c => c.type !== MessageContextType.IMAGE), imageContext];
-    }
+    addImageContext(imageData: string) {
+        // Normalize to a data URL and derive MIME to keep metadata in sync with content
+        const commaIdx = imageData.indexOf(',');
+        const isDataUrl = imageData.startsWith('data:') && commaIdx !== -1;
+        const inferredMime =
+            (isDataUrl ? imageData.slice(5, imageData.indexOf(';')) : null) || 'image/jpeg';
+        const content = isDataUrl ? imageData : `data:${inferredMime};base64,${imageData}`;
+
+        const imageContext: ImageMessageContext = {
+            type: MessageContextType.IMAGE,
+            content,
+            displayName: `Screenshot ${new Date().toLocaleTimeString()}`,
+            mimeType: inferredMime,
+        };
+        this.context = [
+            ...this.context.filter((c) => c.type !== MessageContextType.IMAGE),
+            imageContext,
+        ];
+    }
apps/web/preload/script/api/screenshot.ts (2)

59-66: Center fallback text vertically.

Set textBaseline to 'middle' so the “Screenshot unavailable” message is actually centered.

-            context.textAlign = 'center';
-            context.fillText('Screenshot unavailable', FALLBACK_CANVAS_WIDTH / 2, FALLBACK_CANVAS_HEIGHT / 2);
+            context.textAlign = 'center';
+            context.textBaseline = 'middle';
+            context.fillText('Screenshot unavailable', FALLBACK_CANVAS_WIDTH / 2, FALLBACK_CANVAS_HEIGHT / 2);

199-218: Adjust wrap width to respect padding on both sides.

Current wrap check uses width - TEXT_WRAP_MARGIN, which ignores left/right padding. This causes premature wrapping and uneven text edges.

-            const words = text.split(' ');
-            let line = '';
-            let y = top + TEXT_PADDING;
-            const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
+            const words = text.split(' ');
+            let line = '';
+            let y = top + TEXT_PADDING;
+            const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
+            const wrapWidth = Math.max(0, width - (TEXT_PADDING * 2) - TEXT_WRAP_MARGIN);
...
-                if (metrics.width > width - TEXT_WRAP_MARGIN && line !== '') {
+                if (metrics.width > wrapWidth && line !== '') {
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (4)

4-4: Preserve the forwarded ref — it’s currently ignored.

You switched to a local pillRef but didn’t wire the forwarded ref through, which can break parent behaviors relying on it (focus/measurement).

Do any parents consume this ref? If yes, compose refs as below.

-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useCallback } from 'react';

Inside the component:

-    const pillRef = useRef<HTMLSpanElement>(null);
+    const pillRef = useRef<HTMLSpanElement>(null);
+    const setRefs = useCallback((node: HTMLSpanElement | null) => {
+        pillRef.current = node;
+        if (typeof ref === 'function') ref(node);
+        else if (ref) (ref as React.MutableRefObject<HTMLSpanElement | null>).current = node;
+    }, [ref]);

And on the element:

-            ref={pillRef}
+            ref={setRefs}

32-33: Typo in warn string (“DraftingImagePill”).

Use the actual component name to keep logs searchable.

-        console.warn('DraftingImagePill received non-image context');
+        console.warn('DraftImagePill received non-image context');

43-81: Layering: give the preview a strictly higher z-index than the backdrop.

Both backdrop and image use z-[9999]. Depending on DOM order and stacking contexts, they can overlap unpredictably. Bump the image to z-[10000].

-                                className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[9999]"
+                                className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[9999]"
...
-                            <motion.img
+                            <motion.img
                                 src={context.content}
                                 alt="Screenshot preview"
-                                className="fixed z-[9999] object-contain pointer-events-auto"
+                                className="fixed z-[10000] object-contain pointer-events-auto"

88-101: Avoid setting a key inside the child component.

Keys should be set at the list rendering site (parent). Having key={context.displayName} here is redundant and can cause unwanted remounts if the display name changes.

-            key={context.displayName}
apps/web/client/src/components/store/editor/frames/manager.ts (2)

298-305: updateLastInteraction mutates and notifies (LGTM).

The explicit notify ensures MobX observers react. Minor nit: you could set the new object inline to avoid mutating then setting, but this is fine.

-        if (frameData) {
-            frameData.lastInteractionTime = new Date();
-            this._frameIdToData.set(frameId, frameData);
-            this.notify();
-        }
+        if (frameData) {
+            this._frameIdToData.set(frameId, { ...frameData, lastInteractionTime: new Date() });
+            this.notify();
+        }

307-321: Prefer a frame with a live view when no interactions exist.

If none have interactions, you fall back to frames[0], which may not have a view yet. Optionally prefer the first frame whose view != null to improve UX for screenshot callers.

-        const mostRecent = framesWithInteractions.length > 0 ? framesWithInteractions[0] : frames[0];
+        const mostRecent =
+            framesWithInteractions.length > 0
+                ? framesWithInteractions[0]
+                : (frames.find(f => f.view) ?? frames[0]);
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/input-context-pills.tsx (1)

31-37: Avoid array index as React key for image pills; prefer a stable identifier

Using the index as a key can cause identity churn when items are inserted/removed, which risks preview modal glitches and unnecessary re-mounts. Given the prior collision on content-based keys, consider adding a stable id on the image context (e.g., crypto.randomUUID()) at creation time and using it here.

Apply this minimal change locally, assuming an id is present on the image context:

-            <DraftImagePill
-                key={`image-${index}`}
+            <DraftImagePill
+                key={`image-${(context as any).id ?? index}`}
                 context={context}
                 onRemove={() => handleRemoveContext(context)}
             />

If you’d like, I can follow up with a patch that injects id when creating image contexts in the screenshot/paste flows and update the MessageContext type accordingly.

apps/web/client/src/components/store/editor/screenshot/index.tsx (2)

43-47: Guard against missing projectId before calling capture API

If this.editorEngine.projectId is null/undefined, the mutation will fail. Add a simple guard and reuse the local projectId to avoid accidental undefined payloads.

-            const result = await api.project.captureScreenshot.mutate({ projectId: this.editorEngine.projectId });
+            const projectId = this.editorEngine.projectId;
+            if (!projectId) {
+                console.warn('Screenshot skipped: no projectId available');
+                return;
+            }
+            const result = await api.project.captureScreenshot.mutate({ projectId });

Run-time check: try invoking captureScreenshot() in a project-less state (e.g., immediately after editor init) to ensure we don’t emit a failing request.


23-27: Stabilize this binding for the debounced method

While lodash forwards the call-time this, passing a bare class method still risks losing context if captureScreenshot is detached (e.g., passed around). Converting debouncedCaptureScreenshot to an arrow field eliminates that risk and simplifies reasoning.

-    // 10 second debounce
-    captureScreenshot = debounce(
-        this.debouncedCaptureScreenshot,
-        10000,
-    );
+    // 10 second debounce
+    captureScreenshot = debounce(this.debouncedCaptureScreenshot, 10000);
@@
-    private async debouncedCaptureScreenshot() {
+    private debouncedCaptureScreenshot = async () => {
         if (this.isCapturing) {
             return;
         }
         this.isCapturing = true;
         try {
             // If the screenshot was captured less than 30 minutes ago, skip capturing
             if (this.lastScreenshotAt) {
                 const thirtyMinutesAgo = subMinutes(new Date(), 30);
                 if (isAfter(this.lastScreenshotAt, thirtyMinutesAgo)) {
                     return;
                 }
             }
 
             const projectId = this.editorEngine.projectId;
             if (!projectId) {
                 console.warn('Screenshot skipped: no projectId available');
                 return;
             }
             const result = await api.project.captureScreenshot.mutate({ projectId });
             if (!result || !result.success) {
                 throw new Error('Failed to capture screenshot');
             }
             this.lastScreenshotAt = new Date();
         } catch (error) {
             console.error('Error capturing screenshot', error);
         } finally {
             this.isCapturing = false;
         }
-    }
+    }

Also applies to: 29-53

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (1)

255-262: Optional: add a stable id to image contexts to support reliable pill keys and preview state

If you adopt a stable id per image, the pill’s key can avoid both collisions and index-based churn.

             const contextImage: ImageMessageContext = {
                 type: MessageContextType.IMAGE,
                 content: screenshotData,
                 mimeType: mimeType,
                 displayName: 'Screenshot',
+                // @ts-expect-error: augmenting shape for UI stability (can be formalized in the model)
+                id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
             };

I can put together a small follow-up to formalize id in the MessageContext model and update all usages.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5d6bb58 and d6208c3.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (1 hunks)
  • apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (4 hunks)
  • apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/input-context-pills.tsx (1 hunks)
  • apps/web/client/src/components/store/editor/chat/context.ts (1 hunks)
  • apps/web/client/src/components/store/editor/element/index.ts (1 hunks)
  • apps/web/client/src/components/store/editor/frames/manager.ts (8 hunks)
  • apps/web/client/src/components/store/editor/screenshot/index.tsx (1 hunks)
  • apps/web/client/src/server/api/routers/project/project.ts (0 hunks)
  • apps/web/preload/script/api/screenshot.ts (7 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/client/src/server/api/routers/project/project.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/client/src/components/store/editor/chat/context.ts (1)
packages/models/src/chat/message/context.ts (1)
  • ImageMessageContext (28-31)
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (1)
packages/ui/src/components/sonner.tsx (1)
  • toast (19-19)
apps/web/client/src/components/store/editor/frames/manager.ts (1)
packages/db/src/schema/project/canvas/frame.ts (1)
  • frames (9-22)
🔇 Additional comments (10)
apps/web/client/src/components/store/editor/frames/manager.ts (7)

49-53: Initialize lastInteractionTime per frame (LGTM).

Defaults to null is reasonable; it keeps the signal ephemeral and avoids DB changes.


71-76: Preserve lastInteractionTime on registerView (LGTM).

Good guard against losing the signal when the view re-registers.


98-101: Selecting a frame updates lastInteractionTime (LGTM).

This aligns the “active frame” heuristic with actual user intent.


171-173: Track interaction on navigation (LGTM).

Makes sense to treat navigation as an interaction for “most recent frame” resolution.


225-227: Create: initialize lastInteractionTime to null (LGTM).

Consistent with applyFrames.


258-267: Preserve lastInteractionTime during updates (LGTM).

Prevents accidental loss of the interaction timestamp on partial updates.


298-321: Active frame helper adoption verified.

  • getMostRecentlyInteractedFrame is called in apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (line 226)
  • updateLastInteraction is invoked on element clicks in apps/web/client/src/components/store/editor/element/index.ts (line 79)
  • Internal usages of updateLastInteraction exist only in the frames manager (selection toggles at line 99 and navigation at line 172)

No additional call sites were found; the helpers are used exactly where intended.

apps/web/client/src/components/store/editor/element/index.ts (1)

69-80: Good call: log with frameId and update interaction on click.

The clearer logs help triage; updating lastInteractionTime on element click keeps the “active frame” signal fresh.

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx (2)

225-231: Good shift: target the most recently interacted frame

Switching to editorEngine.frames.getMostRecentlyInteractedFrame() ensures screenshots reflect the user’s current focus. This matches the PR goal and should remove the ambiguity from iterating all frames. Nice.


236-248: Verify the data shape returned by captureScreenshot() matches consumers

handleImageEvent stores a Data URL (e.g., data:image/png;base64,...). Here we store result.data directly. If captureScreenshot() returns raw base64 without the data: prefix, previews and MIME sniffing may fail.

Please confirm the return contract of view.captureScreenshot():

  • If it returns raw base64, wrap it into a Data URL using the resolved mimeType.
  • If it already returns a Data URL, keep as-is.

Example adjustment if raw base64 is returned:

-                    screenshotData = result.data;
-                    mimeType = result.mimeType || 'image/jpeg';
+                    mimeType = result.mimeType || 'image/jpeg';
+                    const raw = result.data;
+                    screenshotData = raw.startsWith('data:')
+                        ? raw
+                        : `data:${mimeType};base64,${raw}`;

Fixed some rendering issues: centered fallback text properly, added projectId guard to prevent undefined API calls, fixed component name typo in console warning, and corrected text wrapping calculation to account for padding.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/preload/script/api/screenshot.ts (2)

224-235: Avoid canvas tainting by skipping non-CORS cross-origin images (prevents “Screenshot unavailable” fallback).

Drawing cross-origin images without crossOrigin set will taint the canvas and cause toDataURL to throw, pushing the whole capture into the red “Screenshot unavailable” fallback—even when only one image caused it. Guard and render a placeholder for unsafe images to keep the rest of the screenshot.

Apply this diff inside renderElement’s image block:

-    if (element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0) {
-        try {
-            context.drawImage(element, left, top, width, height);
-        } catch (error) {
-            context.fillStyle = '#f0f0f0';
-            context.fillRect(left, top, width, height);
-            context.fillStyle = '#999999';
-            context.font = '12px Arial, sans-serif';
-            context.textAlign = 'center';
-            context.fillText('Image', left + width / 2, top + height / 2);
-        }
-    }
+    if (element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0) {
+        // Heuristic: avoid drawing non-CORS cross-origin images that would taint the canvas.
+        let safeToDraw = true;
+        try {
+            const src = element.currentSrc || element.src;
+            const url = new URL(src, document.baseURI);
+            const isCrossOrigin = url.origin !== window.location.origin;
+            const hasCors = !!element.crossOrigin; // 'anonymous' or 'use-credentials'
+            if (isCrossOrigin && !hasCors) safeToDraw = false;
+        } catch {
+            // If URL parsing fails, be conservative.
+            safeToDraw = false;
+        }
+
+        if (safeToDraw) {
+            try {
+                context.drawImage(element, left, top, width, height);
+            } catch {
+                // Fallback placeholder
+                context.fillStyle = '#f0f0f0';
+                context.fillRect(left, top, width, height);
+                context.fillStyle = '#999999';
+                context.font = '12px Arial, sans-serif';
+                context.textAlign = 'center';
+                context.fillText('Image', left + width / 2, top + height / 2);
+            }
+        } else {
+            // Placeholder for skipped cross-origin image
+            context.fillStyle = '#f0f0f0';
+            context.fillRect(left, top, width, height);
+            context.fillStyle = '#999999';
+            context.font = '12px Arial, sans-serif';
+            context.textAlign = 'center';
+            context.fillText('Image', left + width / 2, top + height / 2);
+        }
+    }

188-223: Render form control values (inputs/textareas) instead of missing user-entered text.

Current logic only renders text for elements with no children, which misses values in inputs and textareas. Include explicit handling so screenshots capture user intent.

Apply this diff to add form value rendering:

   if (element.textContent && element.children.length === 0) {
     const text = element.textContent.trim();
     if (text) {
       const fontSize = parseFloat(styles.fontSize) || 16;
       const fontFamily = styles.fontFamily || 'Arial, sans-serif';
       const color = styles.color || '#000000';
@@
       }
     }
   }
+  // Render input/textarea values
+  if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
+      const value = element.value;
+      if (value) {
+          const fontSize = parseFloat(styles.fontSize) || 16;
+          const fontFamily = styles.fontFamily || 'Arial, sans-serif';
+          const color = styles.color || '#000000';
+          context.fillStyle = color;
+          context.font = `${fontSize}px ${fontFamily}`;
+          context.textAlign = 'left';
+          context.textBaseline = 'top';
+          const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
+          const wrapWidth = Math.max(0, width - (TEXT_PADDING * 2) - TEXT_WRAP_MARGIN);
+          const words = value.split(' ');
+          let line = '';
+          let y = top + TEXT_PADDING;
+          for (const word of words) {
+              const testLine = line + word + ' ';
+              const metrics = context.measureText(testLine);
+              if (metrics.width > wrapWidth && line !== '') {
+                  context.fillText(line, left + TEXT_PADDING, y);
+                  line = word + ' ';
+                  y += lineHeight;
+                  if (y > top + height) break;
+              } else {
+                  line = testLine;
+              }
+          }
+          if (line && y <= top + height) {
+              context.fillText(line, left + TEXT_PADDING, y);
+          }
+      }
+      return;
+  }
apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (1)

8-18: Fix: forwarded ref is dropped and typed as HTMLDivElement while the DOM node is a span.

  • The component advertises forwardRef<HTMLDivElement> but renders a <motion.span>. That’s a type contract violation.
  • The forwarded ref parameter is unused, which can break consumers relying on it (focus, scroll-into-view, measurement).

Option A (keep span): change the ref type to HTMLSpanElement and merge the forwarded ref with the local pillRef.

-import React, { useState, useRef } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
@@
-export const DraftImagePill = React.forwardRef<
-    HTMLDivElement,
+export const DraftImagePill = React.forwardRef<
+    HTMLSpanElement,
@@
->(({ context, onRemove }, ref) => {
+>(({ context, onRemove }, ref) => {
@@
-    const pillRef = useRef<HTMLSpanElement>(null);
+    const pillRef = useRef<HTMLSpanElement>(null);
@@
-            ref={pillRef}
+            ref={(node) => {
+                pillRef.current = node;
+                if (typeof ref === 'function') {
+                    ref(node);
+                } else if (ref) {
+                    (ref as React.MutableRefObject<HTMLSpanElement | null>).current = node;
+                }
+            }}

Option B (keep ref type): switch <motion.span> to <motion.div> so the public ref type stays HTMLDivElement. I can supply that patch if you prefer to avoid a public type change.

Also applies to: 88-104

🧹 Nitpick comments (5)
apps/web/preload/script/api/screenshot.ts (3)

23-39: Use devicePixelRatio for crisper text while keeping file size bounded by existing compression.

Rendering at CSS pixel size produces blurry text on HiDPI displays. Scale the canvas by DPR and downscale via your compression step. Cap DPR to keep sizes predictable.

-        // Create a canvas with viewport dimensions
+        // Create a canvas with viewport dimensions, scaled by DPR for clarity
         const canvas = document.createElement('canvas');
         const context = canvas.getContext('2d');
         if (!context) {
             throw new Error('Failed to get canvas context');
         }
 
-        // Set canvas dimensions to viewport size
-        canvas.width = viewportWidth;
-        canvas.height = viewportHeight;
+        const dpr = Math.min(window.devicePixelRatio || 1, 2);
+        canvas.width = Math.floor(viewportWidth * dpr);
+        canvas.height = Math.floor(viewportHeight * dpr);
+        context.scale(dpr, dpr);
 
-        // Warm up canvas context for font rendering
-        context.fillText('', 0, 0);
+        // Warm up canvas context for font rendering (optional)
+        // context.fillText('', 0, 0);

147-151: Stabilize stacking order: tie-break equal z-index by DOM order.

Pure z-index sort is insufficient and can reorder siblings unexpectedly (many are “auto” → 0). Add DOM order as a secondary key to reduce visual glitches.

-    visibleElements.sort((a, b) => {
-        const aZIndex = parseInt(a.styles.zIndex) || 0;
-        const bZIndex = parseInt(b.styles.zIndex) || 0;
-        return aZIndex - bZIndex;
-    });
+    visibleElements.sort((a, b) => {
+        const aZ = parseInt(a.styles.zIndex) || 0;
+        const bZ = parseInt(b.styles.zIndex) || 0;
+        if (aZ !== bZ) return aZ - bZ;
+        // Tie-breaker: document order
+        return a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
+    });

78-117: Make size checks precise and avoid main-thread jank by using toBlob (optional).

  • The size check currently includes the data:image/jpeg;base64, header; strip it for accuracy.
  • Repeated toDataURL calls can stall the UI. toBlob is async and yields to the event loop.

Minimal precision fix:

-            const base64 = scaledCanvas.toDataURL('image/jpeg', quality);
+            const base64 = scaledCanvas.toDataURL('image/jpeg', quality);
+            const commaIdx = base64.indexOf(',');
+            const b64BodyLen = commaIdx >= 0 ? base64.length - (commaIdx + 1) : base64.length;
-            if (base64.length <= MAX_BASE64_SIZE) {
+            if (b64BodyLen <= MAX_BASE64_SIZE) {
                 return base64;
             }

Alternative (bigger refactor): replace the inner loop’s toDataURL with a helper that uses toBlob + FileReader to produce a base64 string asynchronously. I can supply a full patch if you want it.

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (2)

101-103: Remove local key on the root element.

key should be applied by the parent at the array level. Placing it here is redundant and can cause remounts if displayName changes.

-            key={context.displayName}

36-87: Add basic accessibility: close on Escape and announce dialog semantics.

The preview is modal-like. Add Escape-to-close and ARIA attributes for a better a11y baseline.

Minimal additions:

  • Add an effect to close on Escape:
+    useEffect(() => {
+        if (!showPreview) return;
+        const onKeyDown = (e: KeyboardEvent) => {
+            if (e.key === 'Escape') setShowPreview(false);
+        };
+        window.addEventListener('keydown', onKeyDown);
+        return () => window.removeEventListener('keydown', onKeyDown);
+    }, [showPreview]);
  • Add role="dialog" aria-modal="true" aria-label="Screenshot preview" to a wrapper around the preview image (or to the backdrop if you prefer). If you want, I can provide a small wrapper <motion.div> around the image with these attributes and keep your animation intact.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d6208c3 and f2256ec.

📒 Files selected for processing (3)
  • apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (4 hunks)
  • apps/web/client/src/components/store/editor/screenshot/index.tsx (1 hunks)
  • apps/web/preload/script/api/screenshot.ts (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/client/src/components/store/editor/screenshot/index.tsx
🔇 Additional comments (2)
apps/web/preload/script/api/screenshot.ts (1)

17-21: captureScreenshot correctly runs in the selected iframe’s context
I’ve confirmed that all screenshot calls use the Penpal proxy on the target frame, not the top-level document:

• In chat-input/index.tsx (line 237), the code does

const targetFrame = editorEngine.frames.getMostRecentlyInteractedFrame();
const result = await targetFrame.view.captureScreenshot();

so captureScreenshot() executes inside that iframe’s injected preload script.
• In sandbox/index.ts (line 264) and project-breadcrumb.tsx (line 42), calls to

editorEngine.screenshot.captureScreenshot()

also resolve to the selected frame’s view under the hood.
• There is no use of frame.contentWindow, webContents, or top-level document—all IPC happens via the per-frame Penpal connection.

Because getMostRecentlyInteractedFrame() returns the FrameData.view proxy into the iframe, the preload’s captureScreenshot() always captures that frame’s window.document. No changes needed here.

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (1)

3-5: Confirm workspace dependencies for Motion libraries

Please verify that either Motion One (motion) or Framer Motion (framer-motion) is declared in your workspace’s package manifests. We need to ensure the imports you’ve added won’t break the build.

Run the following to check every package.json in the repo:

• Verified files:

  • apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/context-pills/draft-image-pill.tsx (imports motion/react)
  • Various other components mix both motion/react (Motion One) and framer-motion imports.

• Next steps:

  1. Ensure at least one of these dependencies appears in your root or package-level package.json:
    • "motion": "^<version>" (Motion One) to support import { motion } from 'motion/react'
    • "framer-motion": "^<version>" (Framer Motion) to support import { motion } from 'framer-motion'
  2. If both are needed (due to mixed usage), install both in the relevant package(s).
  3. After adding, run pnpm install (or npm install/yarn install) to update the lockfile.

}
const result = await api.project.captureScreenshot.mutate({ projectId: this.editorEngine.projectId });

const projectId = this.editorEngine.projectId;
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this always exist anyway?

onlyMainContent: true,
timeout: 10000,
// Optional: Add actions to click the button for CSB free tier
// actions: [
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we keep this comment? Even as a separate array that doesn't get used. This will be useful for people who don't have CSB pro

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right on both counts. I just checked the EditorEngine and it requires the projectId string parameter.
I'll keep the comment. I'm assuming we'll be testing out and adding it as a feature in the near future? (Going into the design tab and then manually clicking on the button sounds like a lot of inertia)

@reddygtvs
Copy link
Contributor Author

@Kitenite Hi Kiet! It's been a while since I touched this branch. Should I continue working on this by fixing the conflicts due to the more recent changes or leave it be?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants