Skip to content

Conversation

@ben-million
Copy link
Collaborator

@ben-million ben-million commented Feb 3, 2026


Summary by cubic

Add action cycling to the selection label so you can quickly switch actions with the mouse wheel or the C key and auto-run the chosen action after a short idle.

  • New Features

    • Cycle between Copy, Comment, Screenshot, Copy HTML, and Open via scroll or KeyC.
    • Shows a compact action list under the selection label with active highlight and shortcuts.
    • Auto-triggers the active action after 600ms idle; input is throttled and scroll requires a small threshold; respects plugin action enablement.
  • Refactors

    • Extracted resolveActionEnabled and added a shared action context builder used by core, action cycle, and context menu.
    • Added ActionCycle types and state, wired through renderer and selection label.
    • performWithFeedback now supports fallback bounds/selection/position to work outside the context menu.

Written for commit 32df084. Summary will update on new commits.


Note

Medium Risk
Touches core input/event handling and action execution timing (new global wheel/KeyC behavior and auto-trigger), which could cause UX regressions or unexpected action runs if edge cases slip through.

Overview
Adds an action cycling UX on the selection label: users can press C or use the mouse wheel to cycle through a fixed set of actions (e.g. copy/comment/screenshot/open), see the active choice inline, and have the selected action auto-run after a short idle.

Refactors action plumbing by extracting resolveActionEnabled, centralizing action-context construction (including performWithFeedback fallbacks), and tightening overlay event handling so the activation shortcut no longer cancels prompt/input mode when the React Grab textarea is focused. Improves DOM liveness checks by switching many document.contains calls to isElementConnected, and consolidates cache invalidation into clearAllCaches.

Written by Cursor Bugbot for commit 32df084. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Feb 3, 2026

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

Project Deployment Actions Updated (UTC)
react-grab-website Ready Ready Preview, Comment Feb 3, 2026 7:44am

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 3, 2026

Open in StackBlitz

@react-grab/cli

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

grab

npm i https://pkg.pr.new/aidenybai/react-grab/grab@144

@react-grab/ami

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/ami@144

@react-grab/amp

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/amp@144

@react-grab/claude-code

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/claude-code@144

@react-grab/codex

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/codex@144

@react-grab/cursor

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/cursor@144

@react-grab/droid

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/droid@144

@react-grab/gemini

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/gemini@144

@react-grab/opencode

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/opencode@144

react-grab

npm i https://pkg.pr.new/aidenybai/react-grab@144

@react-grab/relay

npm i https://pkg.pr.new/aidenybai/react-grab/@react-grab/relay@144

commit: 32df084

- Improved the structure of the action cycle context by integrating the `handleActionCycleCopy` and `handleActionCyclePrompt` functions for better clarity and maintainability.
- Updated the context-building logic to ensure consistent retrieval of element properties and streamlined the action context creation process.
- Adjusted the handling of frozen elements and selection bounds to improve functionality and user experience.
@aidenybai aidenybai marked this pull request as ready for review February 3, 2026 07:29
Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Medium Priority - The action cycling feature is well-architected but has several issues that could cause bugs in edge cases. Most critical: the global wheel event handler prevents default behavior unconditionally when action cycle is active, which will break normal page scrolling. Also found potential race conditions in timeout cleanup and missing error handling in async action execution.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +2217 to +2225
const handleActionCycleWheel = (event: WheelEvent) => {
const isActionCycleActive = actionCycleActiveIndex() !== null;
if (!isActionCycleActive) return;
if (!canCycleActions()) return;

event.preventDefault();
event.stopPropagation();
actionCycleScrollCycler.handleWheel(event);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

The wheel event handler prevents default unconditionally when action cycle is active (line 2222), which will break normal page scrolling anywhere on the page. This should only preventDefault if the wheel event is actually over the selection label overlay, not globally. Consider checking if the event target is within the action cycle UI before calling preventDefault.

Comment on lines +2137 to +2159
actionCycleIdleTimeoutId = window.setTimeout(() => {
actionCycleIdleTimeoutId = null;
const activeIndex = actionCycleActiveIndex();
const items = actionCycleItems();
if (activeIndex === null || items.length === 0) return;
const selectedItem = items[activeIndex];
if (!selectedItem) return;
const action = getActionById(selectedItem.id);
if (!action) {
resetActionCycle();
return;
}
const context = getActionCycleContext();
if (!context || !resolveActionEnabled(action, context)) {
resetActionCycle();
return;
}
resetActionCycle();
const result = action.onAction(context);
if (result instanceof Promise) {
void result;
}
}, ACTION_CYCLE_IDLE_TRIGGER_MS);
Copy link
Contributor

Choose a reason for hiding this comment

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

Potential race condition: If the action's onAction throws synchronously or returns a rejected promise, the voided promise on line 2157 will silently swallow the error. This makes debugging very difficult. Recommend adding proper error handling:

const result = action.onAction(context);
if (result instanceof Promise) {
  result.catch((error) => {
    console.error('Action cycle action failed:', error);
    // Optionally show error feedback to user
  });
}

Comment on lines +2210 to +2215
const actionCycleScrollCycler = createScrollCycler({
thresholdPx: ACTION_CYCLE_SCROLL_THRESHOLD_PX,
throttleMs: ACTION_CYCLE_INPUT_THROTTLE_MS,
lineHeightPx: ACTION_CYCLE_SCROLL_LINE_HEIGHT_PX,
onStep: handleActionCycleInput,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

actionCycleScrollCycler is created once at module initialization but never cleaned up. While it doesn't hold external resources, for consistency with other cleanup, consider resetting its internal state in onCleanup or when action cycle is reset.

Comment on lines +2162 to +2184
const applyActionCycleItems = (
cycleItems: ActionCycleItem[],
direction: "forward" | "backward",
): boolean => {
if (cycleItems.length === 0) return false;
setActionCycleItems(cycleItems);

const currentIndex = actionCycleActiveIndex();
const isCurrentIndexValid =
currentIndex !== null && currentIndex < cycleItems.length;
const stepOffset = direction === "forward" ? 1 : -1;

let nextIndex: number;
if (!isCurrentIndexValid) {
nextIndex = direction === "forward" ? 0 : cycleItems.length - 1;
} else {
nextIndex =
(currentIndex + stepOffset + cycleItems.length) % cycleItems.length;
}

setActionCycleActiveIndex(nextIndex);
scheduleActionCycleActivation();
return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

Edge case bug: If cycleItems changes between when this function is called and when setActionCycleActiveIndex is called (e.g., due to an action being dynamically disabled), nextIndex could be out of bounds. Consider validating nextIndex < cycleItems.length before calling setActionCycleActiveIndex.

Comment on lines +1 to +4
export const isElementConnected = (
element: Element | null | undefined,
): element is Element =>
Boolean(element?.isConnected ?? element?.ownerDocument?.contains(element));
Copy link
Contributor

Choose a reason for hiding this comment

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

The fallback to ownerDocument.contains is problematic. If isConnected is false, checking ownerDocument.contains could give a different result (e.g., if element is in a detached subtree). Recommend simplifying to just trust isConnected:

export const isElementConnected = (
  element: Element | null | undefined,
): element is Element =>
  element?.isConnected ?? false;

The ownerDocument.contains fallback is only needed for very old browsers that don't support isConnected, which is unlikely in this codebase.

Comment on lines +44 to +50
const now = Date.now();
if (
now - lastStepTimestamp < throttleMs ||
accumulatedDelta < thresholdPx
) {
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The throttle check uses Date.now() which can be affected by system clock changes. For timing-sensitive UX like scroll handling, consider using performance.now() instead for monotonic time.

Comment on lines +2089 to +2109
const getActionCycleContext = (): ContextMenuActionContext | undefined => {
const element = selectionElement();
if (!element) return undefined;

const fallbackBounds = selectionBounds();

return buildActionContext({
element,
filePath: store.selectionFilePath ?? undefined,
lineNumber: store.selectionLineNumber ?? undefined,
tagName: getTagName(element) || undefined,
componentName: selectionComponentName(),
position: store.pointer,
performWithFeedbackOptions: {
fallbackBounds,
fallbackSelectionBounds: fallbackBounds ? [fallbackBounds] : [],
},
shouldDeferHideContextMenu: false,
onBeforePrompt: resetActionCycle,
});
};
Copy link
Contributor

Choose a reason for hiding this comment

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

If selectionElement() becomes null between when availableActionCycleItems is computed and when this context is used (e.g., element removed from DOM), this will return undefined. The calling code at line 2150 checks for this, but there's still a TOCTOU (time-of-check-time-of-use) gap. Consider caching the element reference at the start of the activation flow.

Comment on lines +2800 to +2802
if (actionCycleIdleTimeoutId) {
window.clearTimeout(actionCycleIdleTimeoutId);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Good cleanup! However, consider also clearing the action cycle state here (calling resetActionCycle()) to ensure no orphaned state remains after cleanup.

Comment on lines +467 to +494
<BottomSection>
<div class="flex flex-col w-[calc(100%+16px)] -mx-2 -my-1.5">
<For each={actionCycleItems()}>
{(item, itemIndex) => (
<div
data-react-grab-action-cycle-item={item.label.toLowerCase()}
class="contain-layout flex items-center justify-between w-full px-2 py-1 transition-colors"
classList={{
"bg-black/5":
itemIndex() === actionCycleActiveIndex(),
"rounded-b-[6px]":
itemIndex() === actionCycleItems().length - 1,
}}
>
<span class="text-[13px] leading-4 font-sans font-medium text-black">
{item.label}
</span>
<Show when={item.shortcut}>
<span class="text-[11px] font-sans text-black/50 ml-4">
{formatShortcut(item.shortcut!)}
</span>
</Show>
</div>
)}
</For>
</div>
</BottomSection>
</Show>
Copy link
Contributor

Choose a reason for hiding this comment

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

The action cycle UI is rendered using For which creates/destroys elements on each cycle. If items are reordered frequently (e.g., actions being enabled/disabled), this could cause flashing. Consider using Index instead of For if items have stable order, or add a key function based on item.id.

Comment on lines +2196 to +2207
const handleActionCycleKey = (event: KeyboardEvent): boolean => {
if (event.code !== "KeyC") return false;
if (event.altKey || event.repeat) return false;
if (isKeyboardEventTriggeredByInput(event)) return false;
if (!handleActionCycleInput("forward")) return false;

event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
event.stopImmediatePropagation();
}
return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

The KeyC handler doesn't check if event.metaKey || event.ctrlKey before the main logic, but then conditionally calls stopImmediatePropagation based on those modifiers (lines 2204-2206). This creates inconsistent behavior - either KeyC should always require a modifier, or the stopImmediatePropagation logic should be unconditional. Currently, pressing 'C' alone will cycle actions but won't stop event propagation, which could trigger other 'C' key handlers.

Copy link

@cursor cursor bot left a comment

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.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 17 files

Comment on lines +2800 to +2802
if (actionCycleIdleTimeoutId) {
window.clearTimeout(actionCycleIdleTimeoutId);
}
Copy link

Choose a reason for hiding this comment

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

Suggested change
if (actionCycleIdleTimeoutId) {
window.clearTimeout(actionCycleIdleTimeoutId);
}
resetActionCycle();

Cleanup handler only clears actionCycleIdleTimeoutId but doesn't reset actionCycleItems and actionCycleActiveIndex signals, violating the cleanup invariant

Fix on Vercel


accumulatedDelta += Math.abs(normalizedDelta);

const now = Date.now();
Copy link

Choose a reason for hiding this comment

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

Suggested change
const now = Date.now();
const now = performance.now();

Using Date.now() for scroll throttling timing can break if system clock adjusts backwards, causing incorrect throttle behavior

Fix on Vercel

resetActionCycle();
const result = action.onAction(context);
if (result instanceof Promise) {
void result;
Copy link

Choose a reason for hiding this comment

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

Suggested change
void result;
result.catch((error) => { console.error("[react-grab] Action error:", error); });

Promise rejection from action.onAction() is silently swallowed with void operator, preventing error visibility and making debugging difficult

Fix on Vercel

Comment on lines +2199 to +2206
if (isKeyboardEventTriggeredByInput(event)) return false;
if (!handleActionCycleInput("forward")) return false;

event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
event.stopImmediatePropagation();
}
Copy link

Choose a reason for hiding this comment

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

Suggested change
if (isKeyboardEventTriggeredByInput(event)) return false;
if (!handleActionCycleInput("forward")) return false;
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
event.stopImmediatePropagation();
}
if (event.metaKey || event.ctrlKey) return false;
if (isKeyboardEventTriggeredByInput(event)) return false;
if (!handleActionCycleInput("forward")) return false;
event.preventDefault();
event.stopPropagation();

The handleActionCycleKey function intercepts Cmd+C (Mac) and Ctrl+C (Windows/Linux) shortcuts, preventing normal copy operations from working

Fix on Vercel

@aidenybai aidenybai merged commit 4ad9117 into main Feb 3, 2026
14 checks passed
@aidenybai aidenybai deleted the cycles branch February 3, 2026 08:31
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.

3 participants