Skip to content

feat: implicit viewport grab + CLI peek command#464

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1780984766-implicit-viewport-grab
Open

feat: implicit viewport grab + CLI peek command#464
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1780984766-implicit-viewport-grab

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds "magical implicit grab" — when React Grab is idle, it silently writes visible viewport elements to the clipboard on pointermove/scroll (debounced 3 s), so agents running the react-grab skill get page context without the user explicitly selecting anything.

Browser side (packages/react-grab/):

  • collectViewportElements() — samples a 6×6 grid across the viewport via document.elementsFromPoint, filters through isValidGrabbableElement, deduplicates, caps at 30 elements.
  • createViewportClipboard() — debounced controller that resolves each element's fiber → component name + source location, formats [<tag> in Component (at file:line:col)] references, writes to clipboard via navigator.clipboard.write() with both text/plain and web application/x-react-grab (falls back to writeText). Skips redundant writes by comparing content hash. Payload carries implicit: true to distinguish from explicit grabs.
  • Wired into the existing pointermove + new scroll listener in core/index.tsx — only fires when not in active/holding/copying state. Cleanup on dispose().

CLI side (packages/cli/):

  • New peek command — one-shot clipboard read (no daemon). Reads the structured grab or text fallback, prints one JSON line if a grab is found, exits. Lets skills do npx react-grab peek for a non-blocking viewport snapshot instead of entering the pull watch loop.
npx react-grab peek          # prints grab JSON or exits silently
npx react-grab pull --wait 0 # existing: needs daemon, reads history

Link to Devin session: https://app.devin.ai/sessions/e65f397944e2488f81b02b25e63c2da1
Requested by: @aidenybai


Note

Medium Risk
Background clipboard writes on scroll/pointermove can surprise users or overwrite clipboard content, but only when the tool is idle and writes are debounced/deduplicated; peek is read-only CLI surface area.

Overview
Adds implicit viewport context while React Grab is idle: on debounced pointermove and scroll, the extension samples visible DOM nodes, resolves React source metadata, and writes a grab payload to the clipboard with implicit: true (without activating the overlay).

Browser (react-grab): New collectViewportElements (6×6 grid, capped at 30 grabbable elements) and createViewportClipboard (3s debounce, dedupe, skip unchanged content, text/plain + custom MIME). Hooked from core when enabled and not active/holding/copying; cancelled on dispose.

CLI: New peek command — one-shot clipboard read (no watch daemon), prints a single JSON line for structured grabs or grab-like text (source: viewport/custom/text), otherwise exits silently.

Reviewed by Cursor Bugbot for commit b3b620e. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds implicit viewport grabbing in packages/react-grab to auto-snapshot visible elements when idle, and a new peek command in packages/cli for one-shot clipboard reads without the daemon. This gives agents lightweight page context with no user action.

  • New Features

    • Implicit viewport snapshot (idle only): samples a 6×6 grid, filters/dedupes up to 30 elements, resolves React fiber → component + source, and writes to the clipboard after a 3s debounce on pointermove/scroll. Uses text/plain and web application/x-react-grab and sets implicit: true.
    • CLI peek: npx react-grab peek reads the clipboard once (native or --text-only), prints a single JSON record if a grab is found, then exits. Includes source (viewport/custom/text), implicit, timestamp, entries, content, id, and receivedAt.
  • Bug Fixes

    • Update viewport clipboard content hash only after a successful write; gate writes behind isEnabled(); ensure consistent timestamps in peek output.

Written for commit b3b620e. Summary will update on new commits.

Review in cubic


Open in Devin Review

When React Grab is idle (not actively selecting), automatically collect
visible elements in the viewport and write them to the clipboard on
pointermove/scroll (debounced 3s). The structured payload uses both
text/plain and the web custom MIME type so the existing watcher daemon
recognizes it. Viewport grabs carry an implicit:true flag to distinguish
them from explicit user selections.

Also adds a 'peek' CLI command that reads the clipboard once (no daemon
needed) and prints any grab payload it finds, enabling one-shot reads
for skills that only need the current viewport context.

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-grab-storybook Ready Ready Preview, Comment Jun 9, 2026 6:21am
react-grab-website Ready Ready Preview, Comment Jun 9, 2026 6:21am

@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@react-grab/cli@464
npm i https://pkg.pr.new/grab@464
npm i https://pkg.pr.new/react-grab@464

commit: b3b620e

Comment thread packages/react-grab/src/core/viewport-clipboard.ts
Comment thread packages/react-grab/src/core/viewport-clipboard.ts Outdated
}
},
{ passive: true },
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Scroll listener misses nested scrollers

Medium Severity

The new scroll handler is registered on window with { passive: true } only. scroll events do not bubble, so this listener misses scrolling inside nested overflow containers. Viewport clipboard updates then rely on pointermove, leaving implicit context stale after in-panel scroll.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.


const resolveElementEntry = async (element: Element): Promise<ViewportEntry | null> => {
const fiber = getFiberFromHostInstance(element);
if (!fiber) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fiber lookup skips parent walk

Medium Severity

resolveElementEntry calls getFiberFromHostInstance on the sampled DOM node only. Elsewhere, grabs resolve via findNearestFiberElement, walking ancestors until a host fiber exists. Sampled leaf nodes without their own fiber are dropped, so implicit viewport lists can omit elements explicit copy would include.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.

let isWriting = false;

const performWrite = async (): Promise<void> => {
if (isWriting) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Concurrent write skips scheduled flush

Low Severity

When performWrite is already running, another debounced invocation returns immediately at if (isWriting) return with no follow-up. Long getSource batches can cause a timer-fired update to be dropped until the user moves or scrolls again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac22acf. Configure here.


const formatEntryReference = (entry: ViewportEntry): string => {
const tag = entry.componentName
? `<${entry.tagName}>`

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

formatEntryReference displays the underlying HTML element tag instead of the component name when a component wraps the element

Fix on Vercel

Comment thread packages/react-grab/src/core/viewport-clipboard.ts
Comment thread packages/react-grab/src/utils/collect-viewport-elements.ts
Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>
() => {
if (!isActivated() && !isHoldingKeys() && !isCopying()) {
viewportClipboard.scheduleWrite();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Idle grab overwrites explicit copy

Medium Severity

Implicit scheduleWrite runs whenever the session is not active, not holding, and not copying. After an explicit grab finishes, state becomes justCopied then often inactive, so pointer or scroll activity can debounce a viewport write that replaces the user’s structured clipboard payload.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47b95b6. Configure here.

}
} catch {} finally {
isWriting = false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Write may run after dispose

Low Severity

dispose only calls viewportClipboard.cancel(), which clears the debounce timer. An in-flight performWrite can still finish afterward and call navigator.clipboard.write, updating the clipboard after React Grab has torn down.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47b95b6. Configure here.

return undefined;
};

const resolveElementEntry = async (element: Element): Promise<ViewportEntry | null> => {

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Implicit viewport grabs skip elements when the sampled point lands on a leaf DOM node without a React fiber, whereas explicit grabs would include those elements by walking up the DOM tree

Fix on Vercel

const location = locationParts.length > 0 ? ` ${locationParts.join(" ")}` : "";
return `[${tag}${location}]`;
};

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clipboard write operations are called from a setTimeout callback outside the user gesture context, causing modern browsers to reject them with NotAllowedError

Fix on Vercel

let timerId: ReturnType<typeof setTimeout> | null = null;
let lastContentHash = "";
let isWriting = false;

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Viewport changes are dropped when a new debounced write is scheduled while a previous write is still in progress

Fix on Vercel

viewportClipboard.scheduleWrite();
}
},
{ passive: true },

@vercel vercel Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Scroll event listener on window needs capture: true to catch scroll events from nested overflow: auto containers

Fix on Vercel

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/react-grab/src/core/index.tsx Outdated
Comment on lines +2575 to +2577
if (!isActivated() && !isHoldingKeys() && !isCopying()) {
viewportClipboard.scheduleWrite();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 Viewport clipboard writes continue when react-grab is explicitly disabled

The viewportClipboard.scheduleWrite() calls in both the pointermove and scroll handlers only gate on !isActivated() && !isHoldingKeys() && !isCopying(), but never check isEnabled(). When a user explicitly disables react-grab (via the toolbar or setEnabled(false) API), all three conditions are trivially true (activation is blocked when disabled), so the viewport clipboard keeps writing to the user's clipboard. This is inconsistent with the rest of the codebase: the keydown handler checks isEnabled() at index.tsx:2352, the toolbar action handler at index.tsx:1528, handleComment at index.tsx:1577, and redetectElementUnderPointer at index.tsx:2798.

Suggested change
if (!isActivated() && !isHoldingKeys() && !isCopying()) {
viewportClipboard.scheduleWrite();
}
if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
viewportClipboard.scheduleWrite();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b3b620e — added isEnabled() guard to both pointermove and scroll handlers.

Comment thread packages/react-grab/src/core/index.tsx Outdated
Comment on lines +2605 to +2607
if (!isActivated() && !isHoldingKeys() && !isCopying()) {
viewportClipboard.scheduleWrite();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 Same missing isEnabled() guard on the scroll handler's viewport clipboard write

The scroll event handler at index.tsx:2605 has the same missing isEnabled() check as the pointermove handler, allowing viewport clipboard writes to continue when react-grab is disabled.

Suggested change
if (!isActivated() && !isHoldingKeys() && !isCopying()) {
viewportClipboard.scheduleWrite();
}
if (!isActivated() && !isHoldingKeys() && !isCopying() && isEnabled()) {
viewportClipboard.scheduleWrite();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b3b620e — same isEnabled() guard added here.

Comment on lines +81 to +89
const record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${Date.now()}`,
receivedAt: Date.now(),
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Inconsistent timestamps in text-fallback record due to separate Date.now() calls

In the text-fallback path, timestamp (line 83), the timestamp embedded in id (line 87), and receivedAt (line 88) each call Date.now() independently, so they can produce different values. This is inconsistent with the structured-grab path (lines 66-72) where parsedGrab.timestamp is reused for both timestamp and id. A downstream consumer matching id against timestamp would see a mismatch.

Text-fallback path with three separate Date.now() calls
const record = {
    source: "text",
    timestamp: Date.now(),       // Call 1
    content: snapshot.text,
    entries: [],
    implicit: false,
    id: `peek-${Date.now()}`,    // Call 2 — may differ
    receivedAt: Date.now(),      // Call 3 — may differ
};
Suggested change
const record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${Date.now()}`,
receivedAt: Date.now(),
};
const record = {
source: "text",
timestamp: Date.now(),
content: snapshot.text,
entries: [],
implicit: false,
id: `peek-${Date.now()}`,
receivedAt: Date.now(),
};
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b3b620e — now captures Date.now() once into now and reuses it for timestamp, id, and receivedAt.

Comment on lines +91 to +110
const writeToClipboard = async (content: string, metadata: string): Promise<boolean> => {
if (!navigator.clipboard) return false;

try {
if (typeof ClipboardItem !== "undefined") {
const textBlob = new Blob([content], { type: "text/plain" });
const customBlob = new Blob([metadata], { type: REACT_GRAB_MIME_TYPE });
const item = new ClipboardItem({
"text/plain": textBlob,
[WEB_CUSTOM_MIME_TYPE]: customBlob,
});
await navigator.clipboard.write([item]);
return true;
}
} catch {}

try {
await navigator.clipboard.writeText(content);
return true;
} catch {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🚩 Clipboard API writes will silently fail in most contexts without user activation

The writeToClipboard function in viewport-clipboard.ts:91-110 uses navigator.clipboard.write() and navigator.clipboard.writeText(), both of which require transient user activation in most modern browsers. The scheduleWrite is triggered from pointermove and scroll events (which do not grant user activation) and debounced by 3 seconds (VIEWPORT_CLIPBOARD_DEBOUNCE_MS), meaning any activation from a qualifying event (like pointerdown) would have expired. The calls are wrapped in empty catch blocks so failures are silent. In Chrome on localhost (common for React development), the clipboard-write permission may be auto-granted, making this work. On Firefox/Safari or in production HTTPS contexts, this feature will be a silent no-op. Whether this is acceptable depends on the target deployment scenario.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is by design — the feature targets Chrome on localhost (where clipboard-write is auto-granted), which covers the primary dev-tool use case. The empty catches ensure silent degradation on Firefox/Safari/production HTTPS contexts. No action needed.

…timestamps in peek

Co-Authored-By: Aiden Bai <aiden.bai05@gmail.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 6 total unresolved issues (including 5 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b3b620e. Configure here.

timerId = setTimeout(() => {
timerId = null;
void performWrite();
}, VIEWPORT_CLIPBOARD_DEBOUNCE_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Debounced write overwrites grabs

High Severity

Implicit viewport writes are scheduled while React Grab is idle, but performWrite runs after the debounce without checking whether the user is still idle. A pending timer is not cancelled when activation, key-hold, or copying starts, so an explicit grab on the clipboard can be replaced seconds later by a stale viewport snapshot.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3b620e. Configure here.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Testing Results

Tested the viewport grab feature end-to-end on the e2e-app (localhost:5175) with browser GUI + CLI shell tests.

Feature Tests (6/6 passed)
Test Result
Viewport element collection returns valid DOM nodes ✅ 6 non-root elements at viewport center
No errors on mousemove + debounce ✅ 0 errors after 3 positions + 5s wait
isEnabled() guard prevents writes when disabled ✅ 0 clipboard writes with isEnabled()===false
CLI peek command registered with correct options -d/--dir, --text-only, description present
CLI peek exits cleanly on headless Linux ✅ Exit 1 + "no clipboard reader" (expected)
Code verification: hash dedup + timestamp fixes ✅ Hash set only after didWrite; peek uses single Date.now()
CI Status
  • 15 passed, 1 failed, 3 pending
  • Failed: test (windows-latest)clipboard.test.ts:229 "captures a structured grab end-to-end" timeout at 25s. Pre-existing: clipboard.test.ts has no diff in this PR. Same test also failed on macOS in earlier commits.
  • All critical checks passed: typecheck, lint, build, test-build, test-cli, test (ubuntu + macos), all 4 e2e shards ✅
Notes
  • isEnabled() defaults to false on init (toolbar starts collapsed) — made the guard test straightforward since the disabled state was already active
  • Clipboard writes silently fail on headless Linux (no secure context) — by design for dev-tool use case
  • setEnabled() API couldn't be toggled to true from console (likely SolidJS signal reactivity boundary in devtools eval), but disabled-state guard was fully verified

Devin session

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.

1 participant