Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/react-grab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@
"dependencies": {
"@medv/finder": "^4.0.2",
"bippy": "^0.5.30",
"solid-js": "^1.9.10"
"solid-js": "^1.9.10",
"torph": "^0.0.5"
},
"devDependencies": {
"@babel/core": "^7.28.5",
Expand Down
315 changes: 315 additions & 0 deletions packages/react-grab/src/components/commit-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import { createSignal, createEffect, on, onCleanup } from "solid-js";
import type { Component } from "solid-js";
import type { HistoryItem } from "../types.js";
import { TextMorph } from "./text-morph.js";
import {
COMMIT_WIDGET_FEEDBACK_DURATION_MS,
COMMIT_WIDGET_PROMPT_WIDTH_PX,
Z_INDEX_HOST,
} from "../constants.js";

const UNDO_CLIPBOARD_PROMPT =
"Undo the latest change you just made. Revert the most recent modification to the file(s) you last edited. Do not ask for confirmation — just undo it.";

const DEFAULT_HISTORY_LABEL = "History";

const formatElementLabel = (item: HistoryItem): string => {
if (item.componentName) return `${item.componentName}.${item.tagName}`;
return item.tagName;
};

const HIDDEN_MEDIA_TAGS = new Set(["IMG", "SVG", "PICTURE", "VIDEO", "CANVAS"]);

interface ModifiedElement {
element: HTMLElement | SVGElement;
previousColor: string;
previousTextFillColor: string;
previousTextShadow: string;
previousVisibility: string;
previousFilter: string;
didHideVisibility: boolean;
}

const isStylableElement = (
element: Element,
): element is HTMLElement | SVGElement =>
element instanceof HTMLElement || element instanceof SVGElement;

const hideTextInSubtree = (
element: HTMLElement | SVGElement,
modifiedElements: ModifiedElement[],
) => {
modifiedElements.push({
element,
previousColor: element.style.color,
previousTextFillColor: element.style.getPropertyValue(
"-webkit-text-fill-color",
),
previousTextShadow: element.style.textShadow,
previousVisibility: element.style.visibility,
previousFilter: element.style.filter,
didHideVisibility: HIDDEN_MEDIA_TAGS.has(element.tagName),
});
element.style.color = "transparent";
element.style.setProperty("-webkit-text-fill-color", "transparent");
element.style.textShadow = "none";
element.style.filter = "grayscale(1)";
if (HIDDEN_MEDIA_TAGS.has(element.tagName)) {
element.style.visibility = "hidden";
}

for (const child of Array.from(element.children)) {
if (isStylableElement(child)) {
hideTextInSubtree(child, modifiedElements);
}
}
};

const hidePageTextExcept = (
targetElement: Element,
shadowHost: Element | null,
): ModifiedElement[] => {
const modifiedElements: ModifiedElement[] = [];
let current: Element | null = targetElement;

while (current && current !== document.body && current !== document.documentElement) {
const parent: HTMLElement | null = current.parentElement;
if (!parent) break;

for (const sibling of Array.from(parent.children)) {
if (sibling === current || sibling === shadowHost) continue;
if (isStylableElement(sibling)) {
hideTextInSubtree(sibling, modifiedElements);
}
}

current = parent;
}

return modifiedElements;
};

const restorePageText = (modifiedElements: ModifiedElement[]) => {
for (const {
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing previousFilter in destructuring pattern causes undefined filter restoration in restorePageText function

Fix on Vercel

element,
previousColor,
previousTextFillColor,
previousTextShadow,
previousVisibility,
didHideVisibility,
} of modifiedElements) {
Copy link

Choose a reason for hiding this comment

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

Missing previousFilter in destructuring breaks style restoration

High Severity

The restorePageText function destructures six fields from each ModifiedElement but omits previousFilter. Line 111 then references previousFilter as an undeclared variable. Since hideTextInSubtree applies grayscale(1) to every element's filter style, failing to restore this property means the page will remain grayscaled after the spotlight effect is dismissed.

Additional Locations (1)

Fix in Cursor Fix in Web

element.style.color = previousColor;
if (previousTextFillColor) {
element.style.setProperty(
"-webkit-text-fill-color",
previousTextFillColor,
);
} else {
element.style.removeProperty("-webkit-text-fill-color");
}
element.style.textShadow = previousTextShadow;
element.style.filter = previousFilter;
if (didHideVisibility) {
element.style.visibility = previousVisibility;
}
}
};

interface CommitWidgetProps {
copyCount?: number;
historyItems?: HistoryItem[];
onActivateForCopy?: () => void;
latestGrabbedElement?: Element;
onPromptOpenChange?: (isOpen: boolean) => void;
}

export const CommitWidget: Component<CommitWidgetProps> = (props) => {
const [historyLabel, setHistoryLabel] = createSignal(DEFAULT_HISTORY_LABEL);
const [isPromptOpen, setIsPromptOpen] = createSignal(false);
const [promptText, setPromptText] = createSignal("");
const [promptLabel, setPromptLabel] = createSignal("Prompt");

let promptInputRef: HTMLInputElement | undefined;
let widgetRef: HTMLDivElement | undefined;

createEffect(
on(
() => isPromptOpen(),
(isOpen) => props.onPromptOpenChange?.(isOpen),
),
);

const getShadowHost = (): Element | null => {
if (!widgetRef) return null;
const root = widgetRef.getRootNode();
if (root instanceof ShadowRoot) return root.host;
return null;
};

createEffect(() => {
if (!isPromptOpen() || !props.latestGrabbedElement) return;

const modifiedElements = hidePageTextExcept(
props.latestGrabbedElement,
getShadowHost(),
);

const overlay = document.createElement("div");
overlay.style.cssText =
"position:fixed;box-shadow:0 0 0 9999px rgba(0,0,0,0.08);z-index:2147483644;pointer-events:none";
document.body.appendChild(overlay);

const updateOverlayPosition = () => {
const rect = props.latestGrabbedElement?.getBoundingClientRect();
if (!rect) return;
overlay.style.top = `${rect.top}px`;
overlay.style.left = `${rect.left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};

updateOverlayPosition();
window.addEventListener("scroll", updateOverlayPosition, true);
window.addEventListener("resize", updateOverlayPosition);

onCleanup(() => {
restorePageText(modifiedElements);
overlay.remove();
window.removeEventListener("scroll", updateOverlayPosition, true);
window.removeEventListener("resize", updateOverlayPosition);
});
});

createEffect(
on(
() => props.copyCount ?? 0,
() => {
const latestItem = props.historyItems?.[0];
if (latestItem) {
setHistoryLabel(formatElementLabel(latestItem));
}
setIsPromptOpen(true);
requestAnimationFrame(() => promptInputRef?.focus());
},
{ defer: true },
),
);

const handlePromptSubmit = () => {
const trimmedPromptText = promptText().trim();
if (!trimmedPromptText) return;

void navigator.clipboard.writeText(trimmedPromptText);
setPromptLabel("Copied");
setPromptText("");
setTimeout(() => {
setPromptLabel("Prompt");
setIsPromptOpen(false);
}, COMMIT_WIDGET_FEEDBACK_DURATION_MS);
};
Copy link

Choose a reason for hiding this comment

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

Stale timeout in submit closes prompt unexpectedly

Medium Severity

handlePromptSubmit schedules a setTimeout that calls setIsPromptOpen(false) after 1500ms, but this timer is never cancelled. If copyCount changes within that window (e.g., the user grabs another element), the createEffect reopens the prompt, and then the stale timeout fires and closes it unexpectedly. The timeout reference needs to be stored and cleared when a new copy event arrives.

Additional Locations (1)

Fix in Cursor Fix in Web


const handlePromptButtonClick = () => {
if (isPromptOpen() && promptText().trim()) {
handlePromptSubmit();
return;
}

if (isPromptOpen()) {
setIsPromptOpen(false);
setPromptText("");
return;
}

props.onActivateForCopy?.();
};

const handleWindowKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === "z") {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 1, 2026

Choose a reason for hiding this comment

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

P2: Global Ctrl+Z handling overrides normal undo in editable fields because the listener always calls preventDefault().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/src/components/commit-widget.tsx, line 227:

<comment>Global Ctrl+Z handling overrides normal undo in editable fields because the listener always calls `preventDefault()`.</comment>

<file context>
@@ -0,0 +1,315 @@
+  };
+
+  const handleWindowKeyDown = (event: KeyboardEvent) => {
+    if (event.ctrlKey && event.key === "z") {
+      event.preventDefault();
+      void navigator.clipboard.writeText(UNDO_CLIPBOARD_PROMPT);
</file context>
Fix with Cubic

event.preventDefault();
void navigator.clipboard.writeText(UNDO_CLIPBOARD_PROMPT);
setHistoryLabel("Prompt copied");
setTimeout(() => {
const latestItem = props.historyItems?.[0];
setHistoryLabel(
latestItem ? formatElementLabel(latestItem) : DEFAULT_HISTORY_LABEL,
);
}, COMMIT_WIDGET_FEEDBACK_DURATION_MS);
}
};

window.addEventListener("keydown", handleWindowKeyDown);
onCleanup(() => {
window.removeEventListener("keydown", handleWindowKeyDown);
Comment on lines +240 to +242
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
window.addEventListener("keydown", handleWindowKeyDown);
onCleanup(() => {
window.removeEventListener("keydown", handleWindowKeyDown);
createEffect(() => {
window.addEventListener("keydown", handleWindowKeyDown);
onCleanup(() => {
window.removeEventListener("keydown", handleWindowKeyDown);
});

The keydown event listener registration is placed in the component body outside of any reactive context, violating SolidJS best practices

Fix on Vercel

});
Copy link

Choose a reason for hiding this comment

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

Global Ctrl+Z handler blocks undo in all inputs

Medium Severity

The handleWindowKeyDown listener unconditionally intercepts every Ctrl+Z keypress page-wide with event.preventDefault(), regardless of the event target. This breaks native undo in all text inputs on the page — including the widget's own prompt input — and fires even when there are no history items, copying a meaningless undo prompt to the clipboard.

Fix in Cursor Fix in Web


const computedPromptLabel = () => {
if (promptLabel() !== "Prompt") return promptLabel();
if (isPromptOpen() && promptText().trim()) return "Copy";
return "Prompt";
};

return (
<div
ref={widgetRef}
style={{
position: "fixed",
bottom: "16px",
left: "50%",
transform: "translateX(-50%)",
"z-index": Z_INDEX_HOST,
"pointer-events": "auto",
}}
>
<div
class="commit-widget-container font-sans"
style={{
transform: isPromptOpen() ? "scale(1.2)" : "scale(1)",
}}
>
<div class="commit-widget-button commit-widget-button-disabled">
<TextMorph>{historyLabel()}</TextMorph>
{historyLabel() === DEFAULT_HISTORY_LABEL && (
<kbd class="commit-widget-kbd">⌃Z</kbd>
)}
</div>

<div class="commit-widget-divider" />

<div
class="commit-widget-prompt-input-wrapper"
style={{
width: isPromptOpen()
? `${COMMIT_WIDGET_PROMPT_WIDTH_PX}px`
: "0px",
opacity: isPromptOpen() ? 1 : 0,
}}
>
<input
ref={promptInputRef}
type="text"
value={promptText()}
onInput={(event) => setPromptText(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handlePromptSubmit();
}
if (event.key === "Escape") {
setIsPromptOpen(false);
setPromptText("");
}
}}
placeholder="Type a prompt..."
class="commit-widget-prompt-input"
/>
</div>

<button
class="commit-widget-button commit-widget-button-ghost"
onClick={handlePromptButtonClick}
>
<TextMorph>{computedPromptLabel()}</TextMorph>
</button>
</div>
</div>
);
};
5 changes: 4 additions & 1 deletion packages/react-grab/src/components/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,10 @@ export const ContextMenu: Component<ContextMenuProps> = (props) => {
/>
</div>
<BottomSection>
<div ref={highlightContainerRef} class="relative flex flex-col w-[calc(100%+16px)] -mx-2 -my-1.5">
<div
ref={highlightContainerRef}
class="relative flex flex-col w-[calc(100%+16px)] -mx-2 -my-1.5"
>
<div
ref={highlightRef}
class="pointer-events-none absolute bg-black/5 opacity-0 transition-[top,left,width,height,opacity] duration-75 ease-out"
Expand Down
Loading
Loading