Skip to content

fix(chat): prevent @-mention menu focus loss and stabilize render identity#4218

Merged
waleedlatif1 merged 5 commits intostagingfrom
fix/chat
Apr 18, 2026
Merged

fix(chat): prevent @-mention menu focus loss and stabilize render identity#4218
waleedlatif1 merged 5 commits intostagingfrom
fix/chat

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

Summary

  • Radix FocusScope was restoring focus from the search input to the menu content root whenever registered menu items mounted/unmounted during typing — fixed by always-mounting the default tree under hidden and rendering filtered results as plain <button role="menuitem"> so they don't participate in Radix's Collection
  • Added activeIndex state with ArrowUp/Down/Enter keyboard nav, mouse-hover sync, and scrollIntoView so the highlighted row stays visible (combobox pattern — DOM focus stays on search input, visual highlight moves)
  • Hoisted inline select arrows in useWorkflowMap, useWorkspacesQuery, and useFolderMap to module scope so TanStack Query caches select results across renders instead of returning a new reference every time
  • Replaced setSelectedContexts([]) with a functional updater that bails out when already empty — prevents a fresh [] literal from invalidating consumers keyed on reference identity
  • Wrapped WorkspaceHeader in React.memo now that its props are reference-stable during typing
  • Docs lightbox: capture currentTime on click and seek the lightbox video to match using useLayoutEffect, so GIFs resume from where you left off instead of restarting
  • Docs cleanup: remove dead AnimatedBlocks component, fix language-dropdown to derive currentLang during render instead of mirroring into state via effect, replace template literals with cn()

Type of Change

  • Bug fix

Testing

Tested manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

… components

- Capture currentTime on click and seek lightbox video to match using useLayoutEffect
- Convert lightboxStartTime from useState to useRef (no independent render needed)
- Apply same fix to ActionVideo in action-media.tsx
- Remove dead AnimatedBlocks component (zero imports)
- Fix language-dropdown to derive currentLang during render instead of mirroring into state via effect
- Replace template literals with cn() in faq.tsx and video.tsx
…ntity

Radix DropdownMenu's FocusScope was restoring focus from the search input
to the content root whenever registered menu items mounted or unmounted
inside the content, interrupting typing after a keystroke or two.

- Keep the default tree always mounted under `hidden` instead of swapping
  subtrees when the filter activates.
- Render filtered results as plain <button role="menuitem"> so they do not
  participate in Radix's menu Collection.
- Add activeIndex state with ArrowUp/Down/Enter keyboard nav, mouse-hover
  sync, and scrollIntoView so the highlighted row stays visible and users
  can see what Enter will select.

While tracing the cascade that compounded the bug:

- Hoist `select` in useWorkflowMap / useWorkspacesQuery / useFolderMap to
  module scope so TanStack Query caches the select result across renders.
- Guard setSelectedContexts([]) with a functional updater that bails out
  when already empty, preventing a fresh [] literal from invalidating
  consumers that key on reference identity.
- Wrap WorkspaceHeader in React.memo so it bails out on parent renders
  once its (now-stable) props are unchanged.

Made-with: Cursor
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 18, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Apr 18, 2026 0:27am

Request Review

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 18, 2026

PR Summary

Medium Risk
Changes modify keyboard/focus behavior in a Radix dropdown and memoization/selection logic in React Query hooks; regressions could impact chat input UX and list rendering, but scope is localized and does not touch auth or data writes.

Overview
Fixes the chat “plus menu” resource dropdown so typing in the search input no longer loses focus, by keeping the default menu tree always mounted (toggled via hidden) and rendering filtered results outside Radix’s Collection while adding activeIndex-based keyboard navigation and scroll syncing.

Stabilizes render identity in several places to reduce unnecessary re-renders during typing: useContextManagement now avoids emitting new empty-array references, WorkspaceHeader is wrapped in memo, and React Query hooks (useWorkflowMap, useFolderMap, useWorkspacesQuery) hoist select functions to module scope.

Docs UI tweaks: video lightboxes now open at the clicked video’s current playback time (startTime + useLayoutEffect seek), AnimatedBlocks is removed, and small cleanup refactors switch to cn() and simplify language selection derivation.

Reviewed by Cursor Bugbot for commit 06c8066. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 18, 2026

Greptile Summary

This PR fixes the Radix FocusScope bug that stole keyboard focus from the @-mention search input whenever filtered menu items mounted or unmounted during typing. The fix always-mounts the default Radix tree under the HTML hidden attribute and renders filtered results as plain <button role="menuitem"> elements that bypass Radix's Collection — preventing FocusScope from triggering on type-driven DOM mutations. Alongside this, the PR stabilizes render identity by hoisting inline select arrow functions in three React Query hooks to module scope (enabling structural sharing), applying a functional-updater bail-out in clearContexts, and wrapping WorkspaceHeader in React.memo. The docs side adds a lightbox video seek-to-position feature and cleans up a state-mirroring anti-pattern in the language dropdown.

Confidence Score: 5/5

Safe to merge — no P0 or P1 issues found across any changed file.

All findings are P2 or lower. The architectural fix is sound (always-mounted hidden tree + plain buttons avoids Collection mutations), the React Query select hoisting is a textbook optimization, the functional-updater bail-out is correct, and the lightbox seek uses useLayoutEffect at the right time. No regressions introduced.

No files require special attention.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx Core fix: always-mounts the default Radix tree under hidden and uses plain <button role="menuitem"> for filtered results to prevent Radix FocusScope from stealing focus from the search input; adds keyboard navigation with activeIndex and scroll-into-view.
apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts Adds functional updater bail-out to clearContexts (and the empty-message branch of useEffect) so that returning the same array reference prevents unnecessary downstream memo invalidation.
apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx Wraps component in React.memo (via memo(WorkspaceHeaderImpl)) to prevent re-renders during typing; effective because query select functions are now hoisted to module scope, making props reference-stable.
apps/sim/hooks/queries/workflows.ts Hoists selectWorkflowMap to module scope so React Query can cache the selector result across renders instead of recomputing on every render due to a new function reference.
apps/sim/hooks/queries/workspace.ts Hoists selectWorkspaces to module scope; same optimization as workflows.ts — stable function reference enables React Query's structural sharing for the selected workspace array.
apps/sim/hooks/queries/folders.ts Hoists selectFolderMap to module scope; same optimization pattern as the other query files.
apps/docs/components/ui/lightbox.tsx Adds startTime prop and useLayoutEffect to seek the video to the captured playback position when the lightbox opens, preserving GIF-style resume-from-current-position behavior.
apps/docs/components/ui/action-media.tsx Adds startTimeRef to ActionVideo to capture currentTime on click and forward it as the startTime prop to Lightbox, enabling playback position continuity.
apps/docs/components/ui/video.tsx Adds startTimeRef and captures currentTime before opening the lightbox; forwards it as startTime prop — mirrors the ActionVideo pattern.
apps/docs/components/ui/language-dropdown.tsx Derives currentLang inline from params.lang during render (with a fallback to 'en') instead of mirroring into state via an effect, eliminating the stale-on-first-render window.
apps/docs/components/ui/faq.tsx Minor cleanup — conditional class expressions converted to cn() calls; no logic changes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User types in search input] --> B{filteredItems !== null?}
    B -- No/empty query --> C[div NOT hidden\nRadix DropdownMenuItems visible\nParticipate in Collection]
    B -- Has query --> D[div hidden=true\nRadix items hidden, still in DOM\nDO NOT cause Collection mutations]
    D --> E[Plain buttons rendered\nrole=menuitem, no Collection registration\nNo FocusScope trigger]
    E --> F[DOM focus stays on search input\nactiveIndex moves visual highlight]
    F --> G{Keyboard nav}
    G -- ArrowUp/Down --> H[setActiveIndex, scrollIntoView]
    G -- Enter/Tab --> I[handleSelect → setOpen false]
    I --> J[onCloseAutoFocus\nfocuses textarea]
    C --> K{Keyboard nav}
    K -- ArrowDown --> L[getVisibleMenuItems 0 .focus\nDOM focus moves into Radix tree]
Loading

Reviews (2): Last reviewed commit: "fix(chat): apply same setState bail-out ..." | Re-trigger Greptile

Matches the invariant we already established for the message effect:
calling setSelectedContexts([]) against an already-empty array emits a
fresh [] reference (Object.is bails out are not reference-level), which
cascades through consumers that key on selectedContexts identity.
clearContexts is part of the hook's public API so callers can't know
whether the list is empty — make it safe for them.

Made-with: Cursor
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

Thanks @greptile-apps — looked into both and want to leave my reasoning on the record.

1. clearContexts bail-out — applied in 06c8066. Agreed it's worth the one-line consistency fix: clearContexts is part of the hook's public API and callers can't reasonably check emptiness before invoking it, so making it safe against an already-empty list matches the invariant we just established in the message effect.

2. handleSelect useCallback — intentionally declining. The reasoning:

  • WorkflowFolderTreeItems (the only child that receives handleSelect directly as a prop) is a plain function component, not React.memo'd — see add-resource-dropdown.tsx:190. It re-renders whenever PlusMenuDropdown renders regardless of callback identity, so useCallback here provides zero runtime benefit today.
  • The other handleSelect usages in this file are inline arrow wrappers inside DropdownMenuItem onClick handlers, which similarly don't benefit from stability.
  • The doOpen comparison isn't quite apples-to-apples: doOpen is memoized because it's consumed by useImperativeHandle's dependency array — an identity-sensitive consumer. handleSelect has no such consumer.
  • This project's you-might-not-need-a-callback cleanup pass (sim/.cursor/commands/you-might-not-need-a-callback.md) explicitly flags "useCallback + React.memo unnecessarily — only optimize when you've measured a problem", which is what we'd be doing here for a memoization that doesn't exist yet.

Happy to revisit if/when WorkflowFolderTreeItems gets wrapped in memo — that PR is the right place to memoize its callers.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 18, 2026

Both points are well-reasoned — thanks for the detailed writeup.

On handleSelect: you're right that I overcounted the similarity with doOpen. The useImperativeHandle dependency is genuinely identity-sensitive in a way that handleSelect's callers aren't, and without a memo boundary to cross, the useCallback is dead weight. The cursor rules doc is a good tie-breaker too. Agreed — defer until WorkflowFolderTreeItems is memoized.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules and .greptile/config.

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Copy link
Copy Markdown

@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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 06c8066. Configure here.

@waleedlatif1 waleedlatif1 merged commit 948cdbc into staging Apr 18, 2026
14 checks passed
@waleedlatif1 waleedlatif1 deleted the fix/chat branch April 18, 2026 00:38
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