From a12d17767f8c5c397cc0224d5ea108227a8c862c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:39:14 -0700 Subject: [PATCH] fix(cmdk): keep the first result focused and the top fog stable across re-ranks --- .../command-chrome/command-chrome.test.tsx | 4 ++-- .../command-chrome/command-chrome.tsx | 11 +++++++--- .../search-modal/search-modal.test.tsx | 22 +++++++++++++++++++ .../components/search-modal/search-modal.tsx | 13 +++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx index df755fb3041..f08c6adda6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx @@ -46,7 +46,7 @@ describe('CommandFadedList', () => { vi.unstubAllGlobals() }) - it('fades the palette with the short single mask and the shared search surface', () => { + it('fades the palette with the short pixel-anchored mask and the shared search surface', () => { act(() => { root.render( @@ -58,7 +58,7 @@ describe('CommandFadedList', () => { const list = container.querySelector('[cmdk-list]') const search = container.querySelector('[cmdk-input]')?.parentElement - expect(list?.className).toContain('transparent_8%,black_13%,black_97%') + expect(list?.className).toContain('transparent_36px,black_58px,black_calc(100%_-_13px)') expect(list?.className).not.toContain('scrollbar-track') expect(search?.className).toContain('var(--bg)') }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 58308c8c42b..a71d83db3eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -40,14 +40,19 @@ const SEARCH_SURFACE_CLASSNAME = { /** * The palette hides its scrollbar (`scrollbar-none` at the call site), so it * fades with one plain mask; its band is kept short — fully masked only under - * the floating input (0–8%), legible by 13%, and a brief 97–100% exit — so - * rows spend less time in the fog than on the canvas surface. + * the floating input (0–36px), legible by 58px, and a brief 13px exit — so + * rows spend less time in the fog than on the canvas surface. The palette's + * stops are anchored in pixels (the 448px max-height look frozen) because the + * list shrinks to its content: percentage stops would move the fog on every + * result-count change, a shimmer the dark selected first row makes obvious. + * The canvas list fills a fixed-height card, so its percentage stops never + * move. */ const LIST_FADE_CLASSNAME = { canvas: '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]', palette: - '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)]', + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)]', } as const /** Borderless search field layered over a fading command-result list. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index e2baf2296b0..55df3adc5f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -580,6 +580,28 @@ describe('SearchModal', () => { expect(rows()[1]?.getAttribute('aria-selected')).toBe('false') }) + it('re-anchors selection to the first row after the re-ranked results commit', async () => { + const workflows = [ + { id: 'workflow-1', name: 'Funnel', href: '/workspace/workspace-1/w/workflow-1' }, + { id: 'workflow-2', name: 'Funnel two', href: '/workspace/workspace-1/w/workflow-2' }, + { id: 'workflow-3', name: 'Function alpha', href: '/workspace/workspace-1/w/workflow-3' }, + ] + await act(async () => { + root.render() + }) + + const rows = () => Array.from(document.querySelectorAll('[cmdk-item]')) + + await enterSearchQuery('fun') + expect(rows()[0]?.textContent).toContain('Funnel') + expect(rows()[0]?.getAttribute('aria-selected')).toBe('true') + + await enterSearchQuery('func') + expect(rows()).toHaveLength(1) + expect(rows()[0]?.textContent).toContain('Function alpha') + expect(rows()[0]?.getAttribute('aria-selected')).toBe('true') + }) + it('unmounts while closed and reopens with a blank query', async () => { await act(async () => { root.render() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 51644e619d3..de345848b10 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -634,6 +634,19 @@ function SearchModalContent({ return () => document.removeEventListener('keydown', handleKeyDown) }, []) + /** + * cmdk re-anchors selection on input change against the rows the DOM still + * shows, but ranking runs against the deferred query, so those rows are one + * keystroke stale. When the re-ranked list lands, the stale pick either + * lingers mid-list (it still matches, demoted) or dangles on an unmounted + * row (cmdk only self-heals when the selected row is the last one removed), + * leaving the first visible row unfocused. Re-anchor once the list the + * ranking agrees with has committed. + */ + useEffect(() => { + inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) + }, [deferredSearch]) + const handleBlockSelect = useCallback( (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { const enableTriggerMode =