Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
13 changes: 0 additions & 13 deletions .changeset/grab-source-fetch-lag-fixes.md

This file was deleted.

9 changes: 0 additions & 9 deletions .changeset/style-design-tokens.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/theme-detect-transparent-page.md

This file was deleted.

2 changes: 2 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# @react-grab/cli

## 0.1.48

## 0.1.47

## 0.1.46
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-grab/cli",
"version": "0.1.47",
"version": "0.1.48",
"repository": {
"type": "git",
"url": "git+https://github.com/aidenybai/react-grab.git"
Expand Down
6 changes: 6 additions & 0 deletions packages/grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# grab

## 0.1.48

### Patch Changes

- @react-grab/cli@0.1.48

## 0.1.47

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/grab/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "grab",
"version": "0.1.47",
"version": "0.1.48",
"description": "Select context for coding agents directly from your website",
"keywords": [
"agent",
Expand Down
24 changes: 24 additions & 0 deletions packages/react-grab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
# react-grab

## 0.1.48

### Patch Changes

- bc3a591: Fix the grab hanging on "Grabbing…" when the app saturates the dev server's connection pool. Source resolution (bundle and source-map fetches via bippy, plus Next.js server-frame symbolication) now runs through a concurrency-capped, abortable queue with a timeout, so it no longer queues indefinitely behind the app's own requests. Requires bippy ≥0.5.42 so an aborted source-map fetch no longer poisons bippy's cache and later grabs recover.

Also fixes:

- A click immediately after keyboard navigation selecting a stale element instead of the one under the pointer.
- The page jumping when focus is restored after a grab (focus now restores with `preventScroll`).
- Being unable to select page content while a modal sets `body { pointer-events: none }` (e.g. Radix), via a hit-test override.

Plus activation and drag performance: animations freeze via the Web Animations API instead of a universal-selector recalc, activation batches its layout reads before writes, the React-update freeze walks fibers iteratively, and drag de-duplication is O(n·d) instead of O(n²).

- e56fcc1: Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value.

Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. `Shift` keeps the coarse raw step (×10) and `Alt`/`Option` does a fine raw ±1px step — both opt out of snapping so values can land between tokens. A value sitting outside the scale falls back to a raw step so stepping never dead-ends. When a framework exposes spacing as a single base unit instead of discrete tokens (Tailwind v4's `--spacing`), stepping walks that base-unit grid.

Color tokens are resolved through the browser's own rasterizer, so modern wide-gamut values that `getComputedStyle` returns — `lab()`, `lch()`, `oklab()`, `oklch()`, `color()` — are matched too (these are what Tailwind v4 / shadcn themes compile to), not just hex/rgb/hsl.

- 853ec52: Fix theme detection mis-classifying an undeclared light page as dark for visitors on a dark OS. When a page has no theme marker, no `color-scheme`, and no painted background, detection now derives the real backdrop from the CSS `Canvas` system color instead of guessing from `prefers-color-scheme`. `Canvas` honors the root element's used `color-scheme`, so it stays light under the default `normal` (regardless of the OS) and only tracks the OS preference when the page opts into a dark-capable scheme such as `light dark` - matching exactly what the browser paints behind the page.
- @react-grab/cli@0.1.48

## 0.1.47

### Patch Changes

- 5407d4e: Surface deeper copy context for wrapper-heavy elements. App-owned shared-UI / design-system frames (files under `components/ui/`, `packages/ui/`, `design-system(s)/`, or `primitives/`, e.g. shadcn's `components/ui` or a monorepo `packages/ui`) are now treated like `node_modules` frames: still shown, but exempt from the compact line budget, so a grabbed wrapper digs through its UI primitives to the meaningful feature source by default. Adds a `maxContextLines` option (also settable via the script `data-options` attribute) to raise the budget further for large apps and agent/edit prompts — restoring the option the CLI already writes.

Also hardens the trace: a non-finite/negative `maxContextLines` no longer disables the hard line cap (it falls back to the default), and consecutive duplicate trace lines from shared-UI frames are collapsed so the output stays readable.

- @react-grab/cli@0.1.47

## 0.1.46
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-grab",
"version": "0.1.47",
"version": "0.1.48",
"description": "Select context for coding agents directly from your website",
"keywords": [
"agent",
Expand Down
1 change: 1 addition & 0 deletions packages/react-grab/src/components/overlay-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {

const compositeAllLayers = () => {
if (!mainContext || !canvasRef) return;
if (canvasWidth <= 0 || canvasHeight <= 0) return;

mainContext.setTransform(1, 0, 0, 1, 0, 0);
mainContext.clearRect(0, 0, canvasRef.width, canvasRef.height);
Expand Down
71 changes: 56 additions & 15 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { createElementSelector } from "../utils/create-element-selector.js";
import { isSharedUiSourcePath } from "../utils/is-shared-ui-source-path.js";
import { isNextProjectRuntime } from "../utils/is-next-project-runtime.js";
import { formatComponentNameLines } from "../utils/format-component-name-lines.js";
import { enrichServerFrameLocations, symbolicateServerFrames } from "./next-server-frames.js";
import { runQueuedSourceFetch } from "../utils/source-fetch-queue.js";
import { getHTMLPreview, getInlineHTMLPreview } from "./html-preview.js";
Expand Down Expand Up @@ -233,9 +234,15 @@ export interface StackContextOptions {
interface TraceContextResult {
text: string;
shouldAppendSelectorHint: boolean;
hasBudgetedStackFrame: boolean;
renderedComponentNames: Set<string>;
}

const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => {
const getComponentNamesFromFiber = (
element: Element,
maxCount: number,
shouldIncludeName: (componentName: string) => boolean = () => true,
): string[] => {
if (!isInstrumentationActive()) return [];
const fiber = getFiberFromHostInstance(element);
if (!fiber) return [];
Expand All @@ -247,7 +254,7 @@ const getComponentNamesFromFiber = (element: Element, maxCount: number): string[
if (componentNames.length >= maxCount) return true;
if (isCompositeFiber(currentFiber)) {
const displayName = getDisplayName(currentFiber.type);
if (displayName && isUsefulComponentName(displayName)) {
if (displayName && isUsefulComponentName(displayName) && shouldIncludeName(displayName)) {
componentNames.push(displayName);
}
}
Expand Down Expand Up @@ -367,16 +374,23 @@ export const formatStackContext = (
const hardMaxLines = Math.max(maxLines, MAX_TRACE_CONTEXT_LINES);
const isNextProject = isNextProjectRuntime();
const lines: string[] = [];
const renderedComponentNames = new Set<string>();
let previousLibraryFrameKey: string | null = null;
let didDedupeLeadingComponent = false;
let hasTrustedSource = false;
let hasBudgetedStackFrame = false;
let budgetedLineCount = 0;

const addComponentName = (componentName: string | null | undefined) => {
if (componentName) renderedComponentNames.add(componentName);
};

if (leadingSource) {
hasTrustedSource = leadingSource.origin === "app";
// A shared-UI leading source means the user grabbed a primitive directly;
// keep its budget free so the feature ancestors that consume it surface.
if (!isSharedUiSourcePath(leadingSource.filePath)) budgetedLineCount += 1;
addComponentName(leadingSource.componentName);
lines.push(formatSourceContextLine(leadingSource, isNextProject));
}

Expand Down Expand Up @@ -424,14 +438,20 @@ export const formatStackContext = (
if (frameLine.text === lines[lines.length - 1]) continue;

if (frameLine.isAppSource) hasTrustedSource = true;
if (frameLine.consumesBudget) budgetedLineCount += 1;
if (frameLine.consumesBudget) {
budgetedLineCount += 1;
hasBudgetedStackFrame = true;
}
addComponentName(componentName);
lines.push(frameLine.text);
previousLibraryFrameKey = libraryFrameKey;
}

return {
text: lines.join(""),
shouldAppendSelectorHint: !hasTrustedSource,
hasBudgetedStackFrame,
renderedComponentNames,
};
};

Expand All @@ -442,28 +462,49 @@ const resolveLeadingSource = async (element: Element): Promise<ResolvedSource |
return fiberSource?.origin === "app" ? fiberSource : null;
};

// When the owner stack yields only library or generated sources, the trace
// names the grabbed primitive but not the feature components rendering it.
// The fiber return chain still knows those ancestors, so surface their names.
const appendFiberAncestorNames = (
element: Element,
stackContext: TraceContextResult,
maxAncestorCount: number,
): TraceContextResult => {
const missingAncestorNames = getComponentNamesFromFiber(
findNearestFiberElement(element),
maxAncestorCount,
(ancestorName) =>
isSourceComponentName(ancestorName) && !stackContext.renderedComponentNames.has(ancestorName),
);
if (missingAncestorNames.length === 0) return stackContext;

return {
...stackContext,
text: `${stackContext.text}${formatComponentNameLines(missingAncestorNames)}`,
};
};

const getTraceContext = async (
element: Element,
options: StackContextOptions = {},
): Promise<TraceContextResult> => {
const leadingSource = await resolveLeadingSource(element);
const stack = await getStack(element);

const maxLines = resolveMaxContextLines(options.maxLines);
const stackContext = formatStackContext(stack ?? [], options, leadingSource);
if (stackContext.text) return stackContext;

const componentNames = getComponentNamesFromFiber(
findNearestFiberElement(element),
resolveMaxContextLines(options.maxLines),
);
if (componentNames.length > 0) {
return {
text: componentNames.map((componentName) => `\n in ${componentName}`).join(""),
shouldAppendSelectorHint: true,
};
if (stackContext.text) {
if (stackContext.hasBudgetedStackFrame) return stackContext;
return appendFiberAncestorNames(element, stackContext, maxLines);
}

return { text: "", shouldAppendSelectorHint: true };
const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines);
return {
text: formatComponentNameLines(componentNames),
shouldAppendSelectorHint: true,
hasBudgetedStackFrame: false,
renderedComponentNames: new Set(componentNames),
};
};

export const getStackContext = async (
Expand Down
2 changes: 2 additions & 0 deletions packages/react-grab/src/utils/format-component-name-lines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const formatComponentNameLines = (componentNames: string[]): string =>
componentNames.map((componentName) => `\n in ${componentName}`).join("");
16 changes: 16 additions & 0 deletions packages/react-grab/tests/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,20 @@ describe("formatStackContext", () => {

expect(result.shouldAppendSelectorHint).toBe(false);
});

it("reports when no high-signal stack frame was rendered", () => {
const libraryOnly = formatStackContext([packageFrame], {}, fiberSource);
expect(libraryOnly.hasBudgetedStackFrame).toBe(false);

const withAppFrame = formatStackContext([appFrame], {}, fiberSource);
expect(withAppFrame.hasBudgetedStackFrame).toBe(true);
});

it("collects rendered component names for downstream deduplication", () => {
const result = formatStackContext([packageFrame, appFrame], {}, fiberSource);

expect(result.renderedComponentNames.has("Page")).toBe(true);
expect(result.renderedComponentNames.has("Tabs")).toBe(true);
expect(result.renderedComponentNames.has("Widget")).toBe(true);
});
});
Loading