Unified · Split
| @@ -43,6 +43,10 @@ type ModelPickerItem = { · 42 unmodified lines | |||
| 43 | 43 | ||
| 44 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); | 44 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 45 | 45 | ||
| 46 | function normalizeModelPickerSearchQuery(value: string): string { | ||
| 47 | return value.trim(); | ||
| 48 | } | ||
| 49 | |||
| 46 | // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs | 50 | // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs |
| 47 | // can contain colons (e.g. some vendor model ids), so we only split on the | 51 | // can contain colons (e.g. some vendor model ids), so we only split on the |
| 48 | // first colon — anything after that is the slug. | 52 | // first colon — anything after that is the slug. |
| @@ -57,6 +61,115 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s · 8 unmodified lines | |||
| 57 | }; | 61 | }; |
| 58 | } | 62 | } |
| 59 | 63 | ||
| 64 | function useModelPickerJumpShortcuts(input: { | ||
| 65 | readonly keybindings: ResolvedKeybindingsConfig; | ||
| 66 | readonly modelJumpModelKeys: ReadonlyArray<string>; | ||
| 67 | readonly modelJumpShortcutContext: { | ||
| 68 | readonly terminalFocus: false; | ||
| 69 | readonly terminalOpen: boolean; | ||
| 70 | readonly modelPickerOpen: true; | ||
| 71 | }; | ||
| 72 | readonly onModelSelect: (modelSlug: string, instanceId: ProviderInstanceId) => void; | ||
| 73 | }) { | ||
| 74 | const { keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect } = input; | ||
| 75 | |||
| 76 | useEffect(() => { | ||
| 77 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | ||
| 78 | if (event.defaultPrevented || event.repeat) { | ||
| 79 | return; | ||
| 80 | } | ||
| 81 | |||
| 82 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 83 | platform: navigator.platform, | ||
| 84 | context: modelJumpShortcutContext, | ||
| 85 | }); | ||
| 86 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | ||
| 87 | if (jumpIndex === null) { | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | |||
| 91 | const targetModelKey = modelJumpModelKeys[jumpIndex]; | ||
| 92 | if (!targetModelKey) { | ||
| 93 | return; | ||
| 94 | } | ||
| 95 | const { instanceId, slug } = splitInstanceModelKey(targetModelKey); | ||
| 96 | event.preventDefault(); | ||
| 97 | event.stopPropagation(); | ||
| 98 | onModelSelect(slug, instanceId); | ||
| 99 | }; | ||
| 100 | |||
| 101 | window.addEventListener("keydown", onWindowKeyDown, true); | ||
| 102 | |||
| 103 | return () => { | ||
| 104 | window.removeEventListener("keydown", onWindowKeyDown, true); | ||
| 105 | }; | ||
| 106 | }, [keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect]); | ||
| 107 | } | ||
| 108 | |||
| 109 | function ModelPickerSearchInput(props: { | ||
| 110 | readonly focusRequest: number; | ||
| 111 | readonly onSearchQueryChange: (query: string) => void; | ||
| 112 | readonly onRequestClose: (() => void) | undefined; | ||
| 113 | readonly onHighlightedModelSelect: () => boolean; | ||
| 114 | }) { | ||
| 115 | const [rawSearchQuery, setRawSearchQuery] = useState(""); | ||
| 116 | const searchInputRef = useRef<HTMLInputElement>(null); | ||
| 117 | const normalizedSearchQueryRef = useRef(""); | ||
| 118 | |||
| 119 | useLayoutEffect(() => { | ||
| 120 | searchInputRef.current?.focus({ preventScroll: true }); | ||
| 121 | const frame = window.requestAnimationFrame(() => { | ||
| 122 | searchInputRef.current?.focus({ preventScroll: true }); | ||
| 123 | }); | ||
| 124 | return () => { | ||
| 125 | window.cancelAnimationFrame(frame); | ||
| 126 | }; | ||
| 127 | }, [props.focusRequest]); | ||
| 128 | |||
| 129 | return ( | ||
| 130 | <ComboboxInput | ||
| 131 | ref={searchInputRef} | ||
| 132 | className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" | ||
| 133 | inputClassName="rounded-none bg-transparent text-sm" | ||
| 134 | placeholder="Search models..." | ||
| 135 | showTrigger={false} | ||
| 136 | startAddon={ | ||
| 137 | <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> | ||
| 138 | } | ||
| 139 | value={rawSearchQuery} | ||
| 140 | onChange={(event) => { | ||
| 141 | const nextRawSearchQuery = event.target.value; | ||
| 142 | setRawSearchQuery(nextRawSearchQuery); | ||
| 143 | const nextNormalizedSearchQuery = normalizeModelPickerSearchQuery(nextRawSearchQuery); | ||
| 144 | if (nextNormalizedSearchQuery === normalizedSearchQueryRef.current) { | ||
| 145 | return; | ||
| 146 | } | ||
| 147 | normalizedSearchQueryRef.current = nextNormalizedSearchQuery; | ||
| 148 | props.onSearchQueryChange(nextNormalizedSearchQuery); | ||
| 149 | }} | ||
| 150 | onKeyDown={(event) => { | ||
| 151 | if (event.key === "Escape") { | ||
| 152 | event.preventDefault(); | ||
| 153 | event.stopPropagation(); | ||
| 154 | props.onRequestClose?.(); | ||
| 155 | return; | ||
| 156 | } | ||
| 157 | if (event.key === "Enter" && props.onHighlightedModelSelect()) { | ||
| 158 | (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | ||
| 159 | event.preventDefault(); | ||
| 160 | event.stopPropagation(); | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | event.stopPropagation(); | ||
| 164 | }} | ||
| 165 | onMouseDown={(event) => event.stopPropagation()} | ||
| 166 | onTouchStart={(event) => event.stopPropagation()} | ||
| 167 | size="sm" | ||
| 168 | unstyled | ||
| 169 | /> | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | |||
| 60 | export const ModelPickerContent = memo(function ModelPickerContent(props: { | 173 | export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 61 | /** The instance currently selected in the composer (combobox "value"). */ | 174 | /** The instance currently selected in the composer (combobox "value"). */ |
| 62 | activeInstanceId: ProviderInstanceId; | 175 | activeInstanceId: ProviderInstanceId; |
| @@ -99,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 36 unmodified lines | |||
| 99 | const [searchQuery, setSearchQuery] = useState(""); | 212 | const [searchQuery, setSearchQuery] = useState(""); |
| 100 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); | 213 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); |
| 101 | const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); | 214 | const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); |
| 102 | const searchInputRef = useRef<HTMLInputElement>(null); | 215 | const [searchFocusRequest, setSearchFocusRequest] = useState(0); |
| 103 | const modelListRef = useRef<LegendListRef | null>(null); | 216 | const modelListRef = useRef<LegendListRef | null>(null); |
| 104 | const highlightedModelKeyRef = useRef<string | null>(null); | 217 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 105 | const favorites = useClientSettings((s) => s.favorites ?? []); | 218 | const favorites = useClientSettings((s) => s.favorites ?? []); |
| @@ -119,33 +232,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 13 unmodified lines | |||
| 119 | ); | 232 | ); |
| 120 | const updateSettings = useUpdateClientSettings(); | 233 | const updateSettings = useUpdateClientSettings(); |
| 121 | 234 | ||
| 122 | const focusSearchInput = useCallback(() => { | 235 | const handleSelectInstance = (instanceId: ProviderInstanceId | "favorites") => { |
| 123 | searchInputRef.current?.focus({ preventScroll: true }); | 236 | setSelectedInstanceId(instanceId); |
| 124 | }, []); | 237 | setSearchFocusRequest((request) => request + 1); |
| 125 | 238 | }; | |
| 126 | const handleSelectInstance = useCallback( | ||
| 127 | (instanceId: ProviderInstanceId | "favorites") => { | ||
| 128 | setSelectedInstanceId(instanceId); | ||
| 129 | window.requestAnimationFrame(() => { | ||
| 130 | focusSearchInput(); | ||
| 131 | }); | ||
| 132 | }, | ||
| 133 | [focusSearchInput], | ||
| 134 | ); | ||
| 135 | |||
| 136 | useLayoutEffect(() => { | ||
| 137 | focusSearchInput(); | ||
| 138 | const frame = window.requestAnimationFrame(() => { | ||
| 139 | focusSearchInput(); | ||
| 140 | }); | ||
| 141 | const timeout = window.setTimeout(() => { | ||
| 142 | focusSearchInput(); | ||
| 143 | }, 0); | ||
| 144 | return () => { | ||
| 145 | window.cancelAnimationFrame(frame); | ||
| 146 | window.clearTimeout(timeout); | ||
| 147 | }; | ||
| 148 | }, [focusSearchInput]); | ||
| 149 | 239 | ||
| 150 | // Create a Set for efficient lookup. Favorites are keyed by | 240 | // Create a Set for efficient lookup. Favorites are keyed by |
| 151 | // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind | 241 | // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind |
| @@ -221,7 +311,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 69 unmodified lines | |||
| 221 | }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); | 311 | }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); |
| 222 | 312 | ||
| 223 | const isLocked = props.lockedProvider !== null; | 313 | const isLocked = props.lockedProvider !== null; |
| 224 | const isSearching = searchQuery.trim().length > 0; | 314 | const isSearching = searchQuery.length > 0; |
| 225 | const lockedDisabledInstanceIds = useMemo(() => { | 315 | const lockedDisabledInstanceIds = useMemo(() => { |
| 226 | if (!isLocked) { | 316 | if (!isLocked) { |
| 227 | return undefined; | 317 | return undefined; |
| @@ -261,7 +351,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 33 unmodified lines | |||
| 261 | let result = flatModels; | 351 | let result = flatModels; |
| 262 | 352 | ||
| 263 | // Apply tokenized fuzzy search across the combined provider/model search fields. | 353 | // Apply tokenized fuzzy search across the combined provider/model search fields. |
| 264 | if (searchQuery.trim()) { | 354 | if (searchQuery) { |
| 265 | const rankedMatches = result | 355 | const rankedMatches = result |
| 266 | .map((model) => ({ | 356 | .map((model) => ({ |
| 267 | model, | 357 | model, |
| @@ -385,6 +475,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 117 unmodified lines | |||
| 385 | }, | 475 | }, |
| 386 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], | 476 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], |
| 387 | ); | 477 | ); |
| 478 | const selectHighlightedModel = () => { | ||
| 479 | const highlightedModelKey = highlightedModelKeyRef.current; | ||
| 480 | if (!highlightedModelKey) { | ||
| 481 | return false; | ||
| 482 | } | ||
| 483 | const { instanceId, slug } = splitInstanceModelKey(highlightedModelKey); | ||
| 484 | handleModelSelect(slug, instanceId); | ||
| 485 | return true; | ||
| 486 | }; | ||
| 388 | 487 | ||
| 389 | const toggleFavorite = useCallback( | 488 | const toggleFavorite = useCallback( |
| 390 | (instanceId: ProviderInstanceId, model: string) => { | 489 | (instanceId: ProviderInstanceId, model: string) => { |
| @@ -472,41 +571,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 81 unmodified lines | |||
| 472 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; | 571 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; |
| 473 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); | 572 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); |
| 474 | 573 | ||
| 475 | useEffect(() => { | 574 | useModelPickerJumpShortcuts({ |
| 476 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | 575 | keybindings, |
| 477 | if (event.defaultPrevented || event.repeat) { | 576 | modelJumpModelKeys, |
| 478 | return; | 577 | modelJumpShortcutContext, |
| 479 | } | 578 | onModelSelect: handleModelSelect, |
| 480 | 579 | }); | |
| 481 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 482 | platform: navigator.platform, | ||
| 483 | context: modelJumpShortcutContext, | ||
| 484 | }); | ||
| 485 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | ||
| 486 | if (jumpIndex === null) { | ||
| 487 | return; | ||
| 488 | } | ||
| 489 | |||
| 490 | const targetModelKey = modelJumpModelKeys[jumpIndex]; | ||
| 491 | if (!targetModelKey) { | ||
| 492 | return; | ||
| 493 | } | ||
| 494 | const { instanceId, slug } = splitInstanceModelKey(targetModelKey); | ||
| 495 | event.preventDefault(); | ||
| 496 | event.stopPropagation(); | ||
| 497 | handleModelSelect(slug, instanceId); | ||
| 498 | }; | ||
| 499 | |||
| 500 | window.addEventListener("keydown", onWindowKeyDown, true); | ||
| 501 | |||
| 502 | return () => { | ||
| 503 | window.removeEventListener("keydown", onWindowKeyDown, true); | ||
| 504 | }; | ||
| 505 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | ||
| 506 | 580 | ||
| 507 | useLayoutEffect(() => { | 581 | useLayoutEffect(() => { |
| 508 | setShowTopScrollFade(false); | ||
| 509 | setShowBottomScrollFade(filteredModelKeys.length > 5); | ||
| 510 | let nestedFrame = 0; | 582 | let nestedFrame = 0; |
| 511 | const frame = window.requestAnimationFrame(() => { | 583 | const frame = window.requestAnimationFrame(() => { |
| 512 | updateModelListScrollFades(); | 584 | updateModelListScrollFades(); |
| @@ -577,42 +649,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 64 unmodified lines | |||
| 577 | {/* Search bar */} | 649 | {/* Search bar */} |
| 578 | <div className="px-4 pt-2.5"> | 650 | <div className="px-4 pt-2.5"> |
| 579 | <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring"> | 651 | <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring"> |
| 580 | <ComboboxInput | 652 | <ModelPickerSearchInput |
| 581 | ref={searchInputRef} | 653 | focusRequest={searchFocusRequest} |
| 582 | className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" | 654 | onSearchQueryChange={setSearchQuery} |
| 583 | inputClassName="rounded-none bg-transparent text-sm" | 655 | onRequestClose={props.onRequestClose} |
| 584 | placeholder="Search models..." | 656 | onHighlightedModelSelect={selectHighlightedModel} |
| 585 | showTrigger={false} | ||
| 586 | startAddon={ | ||
| 587 | <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> | ||
| 588 | } | ||
| 589 | value={searchQuery} | ||
| 590 | onChange={(e) => setSearchQuery(e.target.value)} | ||
| 591 | onKeyDown={(e) => { | ||
| 592 | if (e.key === "Escape") { | ||
| 593 | e.preventDefault(); | ||
| 594 | e.stopPropagation(); | ||
| 595 | props.onRequestClose?.(); | ||
| 596 | return; | ||
| 597 | } | ||
| 598 | if (e.key === "Enter" && highlightedModelKeyRef.current) { | ||
| 599 | ( | ||
| 600 | e as typeof e & { preventBaseUIHandler?: () => void } | ||
| 601 | ).preventBaseUIHandler?.(); | ||
| 602 | e.preventDefault(); | ||
| 603 | e.stopPropagation(); | ||
| 604 | const { instanceId, slug } = splitInstanceModelKey( | ||
| 605 | highlightedModelKeyRef.current, | ||
| 606 | ); | ||
| 607 | handleModelSelect(slug, instanceId); | ||
| 608 | return; | ||
| 609 | } | ||
| 610 | e.stopPropagation(); | ||
| 611 | }} | ||
| 612 | onMouseDown={(e) => e.stopPropagation()} | ||
| 613 | onTouchStart={(e) => e.stopPropagation()} | ||
| 614 | size="sm" | ||
| 615 | unstyled | ||
| 616 | /> | 657 | /> |
| 617 | </div> | 658 | </div> |
| 618 | </div> | 659 | </div> |