diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 916b66a32b34..2bd7c118c69f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; import { buildBrowseGroups, buildCommandPaletteProjectMetadata, + buildCommandPaletteRows, buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, type CommandPaletteActionItem, type CommandPaletteGroup, @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { ]); }); }); + +describe("virtualized command palette rows", () => { + const action = (value: string, disabled = false): CommandPaletteActionItem => ({ + kind: "action", + value, + searchTerms: [], + title: value, + icon: null, + ...(disabled ? { disabled } : {}), + run: async () => {}, + }); + const groups: CommandPaletteGroup[] = [ + { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, + { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, + ]; + + it("keeps group order and headings while indexing only enabled items", () => { + const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); + + expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ + "# Actions", + "actions:new-thread", + "actions:offline", + "# Threads", + "threads:thread-a", + "threads:thread-b", + ]); + expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); + expect(rowIndexByItemIndex).toEqual([1, 4, 5]); + expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ + 0, + null, + 1, + 2, + ]); + }); + + it("resolves Enter to the highlighted item without needing its row mounted", () => { + expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); + expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d031f36ed35c..49e963f68313 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -172,6 +172,65 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +export type CommandPaletteRow = + | { + readonly kind: "label"; + readonly key: string; + readonly label: string; + readonly first: boolean; + } + | { + readonly kind: "item"; + readonly key: string; + readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + /** Position among enabled items, or null for disabled rows the keyboard skips. */ + readonly itemIndex: number | null; + }; + +/** + * Flattens groups into the rows a virtualized list renders. `itemValues` is the + * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a + * highlight back to its row for scrolling. + */ +export function buildCommandPaletteRows(groups: ReadonlyArray) { + const rows: CommandPaletteRow[] = []; + const itemValues: string[] = []; + const rowIndexByItemIndex: number[] = []; + for (const group of groups) { + if (group.label) { + rows.push({ + kind: "label", + key: `group:${group.value}`, + label: group.label, + first: rows.length === 0, + }); + } + for (const item of group.items) { + const itemIndex = item.disabled ? null : itemValues.length; + if (itemIndex !== null) { + itemValues.push(item.value); + rowIndexByItemIndex.push(rows.length); + } + rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); + } + } + return { rows, itemValues, rowIndexByItemIndex }; +} + +/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ +export function findHighlightedCommandPaletteItem( + groups: ReadonlyArray, + highlightedItemValue: string | null, +): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { + if (highlightedItemValue === null) return null; + for (const group of groups) { + for (const item of group.items) { + if (item.value === highlightedItemValue && !item.disabled) return item; + } + } + return null; +} + export function enumerateCommandPaletteItems( items: ReadonlyArray, ): CommandPaletteActionItem[] { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aad0e71a06b0..f7b78b5ae676 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -77,6 +77,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -157,7 +158,9 @@ import { buildRootGroups, buildThreadActionItems, buildLinkedThreadActionItems, + buildCommandPaletteRows, enumerateCommandPaletteItems, + findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteSubmenuItem, @@ -174,7 +177,10 @@ import { import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; -import { CommandPaletteResults } from "./CommandPaletteResults"; +import { + CommandPaletteVirtualizedResults, + scrollCommandPaletteRowIntoView, +} from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); + const resultListRef = useRef(null); + // Typing or entering a submenu clears the highlight. Base UI keeps its own on the + // first row, but the palette shows none until the user navigates, and the first + // ArrowDown lands on it. + const highlightClearedRef = useRef(false); + function clearTypedHighlight(): void { + highlightClearedRef.current = true; + setHighlightedItemValue(null); + } const clientSettings = useClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { ? { source: match.source, snippet: match.snippet, - query: threadSearchQuery, + query: threadSearch.query, } : undefined; }, @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { projectTitleById, providerEntryByEnvironmentAndInstanceId, threadContentMatchByKey, - threadSearchQuery, + threadSearch.query, threads, ], ); @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), }, ]); + highlightClearedRef.current = true; setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { function handleQueryChange(nextQuery: string): void { browseNavigation.invalidate(); - setHighlightedItemValue(null); + clearTypedHighlight(); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { popView(); @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { } else if (isBrowsing) { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } + const resultRows = buildCommandPaletteRows(displayedGroups); + const autoHighlightsFirstRow = + !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; const inputPlaceholder = newProjectFlow !== null @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); popView(); + return; + } + + // Base UI ignores navigation keys with modifiers, so these fallbacks do too. + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + // Base UI only keeps a hidden highlight on the first row when it auto-highlights. + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; + if ( + event.key === "ArrowDown" && + highlightClearedRef.current && + firstItemValue && + !event.nativeEvent.isComposing + ) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + highlightClearedRef.current = false; + setHighlightedItemValue(firstItemValue); + scrollCommandPaletteRowIntoView( + resultListRef.current, + resultRows.rowIndexByItemIndex[0] ?? 0, + ); + return; + } + + // Base UI clicks the highlighted row on Enter, which does nothing once the + // virtualized list has unmounted it, so run the tracked highlight directly. + if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? firstItemValue ?? null, + ); + if (highlightedItem) { + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + executeItem(highlightedItem); + } } } @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { { + items={resultRows.itemValues} + virtualized + onItemHighlighted={(value, eventDetails) => { + if (eventDetails.reason === "none" && highlightClearedRef.current) return; + highlightClearedRef.current = false; setHighlightedItemValue(typeof value === "string" ? value : null); + const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; + if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { + scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); + } }} onValueChange={handleQueryChange} showBackHint={isSubmenu} @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: { ) : null} - void; } +function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) { + return ( +
+ {props.emptyStateMessage ?? + (props.isActionsOnly + ? "No matching actions." + : "No matching commands, projects, or threads.")} +
+ ); +} + export function CommandPaletteResults(props: CommandPaletteResultsProps) { if (props.groups.length === 0) { - return ( -
- {props.emptyStateMessage ?? - (props.isActionsOnly - ? "No matching actions." - : "No matching commands, projects, or threads.")} -
- ); + return ; } return ( @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +/** + * Scrolls a keyboard highlight into view the way the unvirtualized list did: + * nearest edge, clear of the scroll fade. Rows outside the rendered window + * fall back to the list's own scrolling. + */ +export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) { + const element = list?.getState?.().elementAtIndex(rowIndex); + if (element instanceof HTMLElement) { + element.scrollIntoView({ block: "nearest" }); + return; + } + void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false }); +} + +interface CommandPaletteVirtualizedResultsProps extends Omit { + rows: ReadonlyArray; + listRef: RefObject; +} + +/** + * Renders only the visible rows. The parent passes the flat item order to the + * Command root as `items` with `virtualized`, scrolls keyboard highlights into + * view through `listRef`, and runs Enter itself since the row may be unmounted. + */ +export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) { + const { listRef } = props; + const [scrollFade, setScrollFade] = useState({ top: false, bottom: false }); + const updateScrollFade = useCallback(() => { + const scrollElement = listRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) return; + const top = scrollElement.scrollTop > 1; + const bottom = + scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1; + setScrollFade((current) => + current.top === top && current.bottom === bottom ? current : { top, bottom }, + ); + }, [listRef]); + + if (props.rows.length === 0) { + return ; + } + + return ( + + + ref={listRef} + data={props.rows} + keyExtractor={(row) => row.key} + getItemType={(row) => row.kind} + extraData={props.highlightedItemValue} + renderItem={({ item: row }) => + row.kind === "label" ? ( +
+ {row.label} +
+ ) : row.itemIndex === null ? ( + + ) : ( + + ) + } + estimatedItemSize={40} + drawDistance={400} + onLayout={updateScrollFade} + onScroll={updateScrollFade} + contentContainerClassName="px-2" + className={cn( + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + getVirtualizedScrollFadeClassName(scrollFade), + )} + /> +
+ ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { } function CommandPaletteResultRow(props: { + index?: number; item: CommandPaletteActionItem | CommandPaletteSubmenuItem; isActive: boolean; keybindings: ResolvedKeybindingsConfig; @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { return ( { diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 78753c36097b..cbcb58897157 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -111,7 +111,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", // Adjacent attachments share their outline, including notices outside the form. "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", - "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", + "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", className, )} {...props} diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 8251d8c24b7c..548efd944897 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. ); } +const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; + function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { return ( + ); +} + +// A group label for virtualized lists, where rows cannot be wrapped in a group. +function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { + return ( + ); @@ -226,6 +250,8 @@ export { AutocompleteGroupLabel, AutocompleteEmpty, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, AutocompleteClear, AutocompleteCollection, }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index d3a83865151d..b3a473ba1d22 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -12,6 +12,8 @@ import { AutocompleteInput, AutocompleteItem, AutocompleteList, + AutocompleteListHeading, + AutocompleteListVirtualized, } from "~/components/ui/autocomplete"; import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; import { Button } from "~/components/ui/button"; @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandListHeading({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { return (
({ + matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], + isLoading: false, +})); +vi.mock("@effect/atom-react", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: (atom: { label?: ReadonlyArray }) => + String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, +})); + +const environmentIds = [EnvironmentId.make("local")]; +type ThreadSearch = ReturnType; + +function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { + props.onResult(useThreadSearch(environmentIds, props.query)); + return null; +} + +describe("useThreadSearch", () => { + let renderer: ReactTestRenderer | null = null; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", globalThis); + }); + afterEach(() => { + act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("keeps the settled query and matches stable while typing, then reports the settled query", () => { + const results: ThreadSearch[] = []; + const render = (query: string) => { + const element = results.push(result)} />; + act(() => { + if (renderer) renderer.update(element); + else renderer = create(element); + }); + return results.at(-1)!; + }; + + render(""); + const typing = ["li", "lin", "link "].map(render); + expect(typing.map((result) => result.query)).toEqual(["", "", ""]); + expect(new Set(typing.map((result) => result.matches)).size).toBe(1); + expect(typing.every((result) => result.isPending)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + const settled = results.at(-1)!; + expect(settled.query).toBe("link"); + expect(settled.matches).toBe(searchResults.matches); + expect(settled.isPending).toBe(false); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 15211efab4e6..01bfa8864bb7 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -72,6 +72,8 @@ export function useThreadSearch( query: string, ): { readonly matches: ReadonlyArray; + /** The settled query `matches` came from; it only changes when results do. */ + readonly query: string; readonly isPending: boolean; } { const normalizedQuery = query.trim(); @@ -88,6 +90,7 @@ export function useThreadSearch( const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; return { matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, + query: settledQuery ?? "", isPending: canSearch && (isDebouncing || result.isLoading), }; }