| author | |
| committer | |
| log | 31533466b09ba12ec520e61c7908841c0b55de01 |
| tree | aed12cb32576cd919238d1f2ae7b42bb20f894e4 |
| parent | 7f741a56d176617d7f97865de24dbdf11ea3fd7e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: ss <ss@barekey.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>11 files changed, 701 insertions(+), 434 deletions(-)
apps/web/src/components/ChatView.tsx+18| ... | ... | @@ -3601,6 +3601,23 @@ export default function ChatView(props: ChatViewProps) { |
| 3601 | 3601 | composerRef, |
| 3602 | 3602 | ]); |
| 3603 | 3603 | |
| 3604 | const getModelDisabledReason = useCallback( | |
| 3605 | (instanceId: ProviderInstanceId, model: string): string | null => { | |
| 3606 | if (!activeThread) { | |
| 3607 | return null; | |
| 3608 | } | |
| 3609 | const reason = getStartedThreadModelChangeBlockReason({ | |
| 3610 | providers: providerStatuses, | |
| 3611 | hasStartedSession: activeThread.session !== null, | |
| 3612 | currentModelSelection: activeThread.modelSelection, | |
| 3613 | currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, | |
| 3614 | nextModelSelection: { instanceId, model }, | |
| 3615 | }); | |
| 3616 | return reason ? `${reason.description} Start a new thread to use this model.` : null; | |
| 3617 | }, | |
| 3618 | [activeThread, providerStatuses], | |
| 3619 | ); | |
| 3620 | ||
| 3604 | 3621 | const onProviderModelSelect = useCallback( |
| 3605 | 3622 | (instanceId: ProviderInstanceId, model: string) => { |
| 3606 | 3623 | if (!activeThread) return; |
| ... | ... | @@ -3919,6 +3936,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3919 | 3936 | onChangeActivePendingUserInputCustomAnswer |
| 3920 | 3937 | } |
| 3921 | 3938 | onProviderModelSelect={onProviderModelSelect} |
| 3939 | getModelDisabledReason={getModelDisabledReason} | |
| 3922 | 3940 | toggleInteractionMode={toggleInteractionMode} |
| 3923 | 3941 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 3924 | 3942 | handleInteractionModeChange={handleInteractionModeChange} |
apps/web/src/components/chat/ChatComposer.tsx+3| ... | ... | @@ -489,6 +489,7 @@ export interface ChatComposerProps { |
| 489 | 489 | ) => void; |
| 490 | 490 | |
| 491 | 491 | onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; |
| 492 | getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; | |
| 492 | 493 | toggleInteractionMode: () => void; |
| 493 | 494 | handleRuntimeModeChange: (mode: RuntimeMode) => void; |
| 494 | 495 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; |
| ... | ... | @@ -563,6 +564,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 563 | 564 | onPreviousActivePendingUserInputQuestion, |
| 564 | 565 | onChangeActivePendingUserInputCustomAnswer, |
| 565 | 566 | onProviderModelSelect, |
| 567 | getModelDisabledReason, | |
| 566 | 568 | toggleInteractionMode, |
| 567 | 569 | handleRuntimeModeChange, |
| 568 | 570 | handleInteractionModeChange, |
| ... | ... | @@ -2347,6 +2349,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2347 | 2349 | onOpenChange={(open) => { |
| 2348 | 2350 | setIsComposerModelPickerOpen(open); |
| 2349 | 2351 | }} |
| 2352 | getModelDisabledReason={getModelDisabledReason} | |
| 2350 | 2353 | onInstanceModelChange={onProviderModelSelect} |
| 2351 | 2354 | /> |
| 2352 | 2355 |
apps/web/src/components/chat/ModelListRow.tsx+79-62| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | 2 | import { memo } from "react"; |
| 3 | import { StarIcon } from "lucide-react"; | |
| 3 | import { CheckIcon, StarIcon } from "lucide-react"; | |
| 4 | 4 | import { |
| 5 | 5 | getDisplayModelName, |
| 6 | 6 | getTriggerDisplayModelLabel, |
| ... | ... | @@ -27,11 +27,13 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 27 | 27 | providerDisplayName: string; |
| 28 | 28 | providerAccentColor?: string | undefined; |
| 29 | 29 | isFavorite: boolean; |
| 30 | isSelected: boolean; | |
| 30 | 31 | showProvider: boolean; |
| 31 | 32 | preferShortName?: boolean; |
| 32 | 33 | useTriggerLabel?: boolean; |
| 33 | 34 | showNewBadge?: boolean; |
| 34 | 35 | jumpLabel?: string | null; |
| 36 | disabledReason?: string | null; | |
| 35 | 37 | onToggleFavorite: () => void; |
| 36 | 38 | }) { |
| 37 | 39 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| ... | ... | @@ -39,85 +41,100 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 39 | 41 | ? `${props.providerDisplayName} · ${props.model.subProvider}` |
| 40 | 42 | : props.providerDisplayName; |
| 41 | 43 | |
| 42 | return ( | |
| 44 | const row = ( | |
| 43 | 45 | <ComboboxItem |
| 44 | 46 | hideIndicator |
| 45 | 47 | index={props.index} |
| 46 | 48 | value={`${props.instanceId}:${props.model.slug}`} |
| 47 | contentClassName="flex w-full items-start gap-2" | |
| 49 | disabled={Boolean(props.disabledReason)} | |
| 50 | contentClassName="flex w-full items-center gap-3" | |
| 48 | 51 | className={cn( |
| 49 | "w-full cursor-pointer rounded px-3 py-2 transition-colors group", | |
| 50 | "data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground", | |
| 52 | "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", | |
| 53 | "data-highlighted:bg-muted/56 data-selected:bg-transparent data-selected:text-foreground data-selected:ring-0", | |
| 54 | props.disabledReason && | |
| 55 | "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", | |
| 51 | 56 | )} |
| 52 | 57 | > |
| 53 | <Tooltip> | |
| 54 | <TooltipTrigger | |
| 55 | render={ | |
| 56 | <button | |
| 57 | className="mt-0.5 shrink-0 cursor-pointer opacity-40 transition-opacity group-hover:opacity-100" | |
| 58 | onClick={(event) => { | |
| 59 | event.stopPropagation(); | |
| 60 | props.onToggleFavorite(); | |
| 61 | }} | |
| 62 | onKeyDown={(event) => { | |
| 63 | event.stopPropagation(); | |
| 64 | }} | |
| 65 | type="button" | |
| 66 | aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} | |
| 67 | > | |
| 68 | <StarIcon | |
| 69 | className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")} | |
| 70 | /> | |
| 71 | </button> | |
| 72 | } | |
| 73 | /> | |
| 74 | <TooltipPopup side="top" align="center"> | |
| 75 | {props.isFavorite ? "Remove from favorites" : "Add to favorites"} | |
| 76 | </TooltipPopup> | |
| 77 | </Tooltip> | |
| 78 | ||
| 79 | 58 | <div className="min-w-0 flex-1 text-left"> |
| 80 | <div className="flex items-center justify-between gap-2 min-w-0"> | |
| 81 | <div className="text-xs font-medium leading-snug flex items-center gap-2 min-w-0"> | |
| 82 | <span className="truncate"> | |
| 83 | {props.useTriggerLabel | |
| 84 | ? getTriggerDisplayModelLabel(props.model) | |
| 85 | : getDisplayModelName( | |
| 86 | props.model, | |
| 87 | props.preferShortName ? { preferShortName: true } : undefined, | |
| 88 | )} | |
| 89 | </span> | |
| 90 | {props.showNewBadge ? ( | |
| 91 | <span | |
| 92 | className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200" | |
| 93 | aria-label="New model" | |
| 94 | > | |
| 95 | New | |
| 96 | </span> | |
| 97 | ) : null} | |
| 59 | <div className="flex min-w-0 items-center gap-2"> | |
| 60 | <div className="min-w-0 truncate text-xs font-medium leading-snug"> | |
| 61 | {props.useTriggerLabel | |
| 62 | ? getTriggerDisplayModelLabel(props.model) | |
| 63 | : getDisplayModelName( | |
| 64 | props.model, | |
| 65 | props.preferShortName ? { preferShortName: true } : undefined, | |
| 66 | )} | |
| 98 | 67 | </div> |
| 99 | {props.jumpLabel ? ( | |
| 100 | <Kbd className="h-4 min-w-0 shrink-0 rounded-sm px-1.5 text-[10px]"> | |
| 101 | {props.jumpLabel} | |
| 102 | </Kbd> | |
| 68 | {props.isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null} | |
| 69 | {props.showNewBadge ? ( | |
| 70 | <span | |
| 71 | className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200" | |
| 72 | aria-label="New model" | |
| 73 | > | |
| 74 | New | |
| 75 | </span> | |
| 103 | 76 | ) : null} |
| 104 | 77 | </div> |
| 105 | 78 | {props.showProvider && ( |
| 106 | <div className="flex items-center gap-1 mt-0.5"> | |
| 79 | <div className="mt-1 flex items-center gap-1.5"> | |
| 107 | 80 | {ProviderIcon ? <ProviderIcon className="size-3 shrink-0" /> : null} |
| 108 | {props.providerAccentColor ? ( | |
| 109 | <span | |
| 110 | className="size-1.5 shrink-0 rounded-full" | |
| 111 | style={{ backgroundColor: props.providerAccentColor }} | |
| 112 | aria-hidden | |
| 113 | /> | |
| 114 | ) : null} | |
| 115 | <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate"> | |
| 81 | <span className="truncate text-xs font-normal leading-snug text-muted-foreground/70"> | |
| 116 | 82 | {providerLabel} |
| 117 | 83 | </span> |
| 118 | 84 | </div> |
| 119 | 85 | )} |
| 120 | 86 | </div> |
| 87 | ||
| 88 | <div className="flex shrink-0 items-center gap-1.5"> | |
| 89 | {props.jumpLabel ? ( | |
| 90 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd> | |
| 91 | ) : null} | |
| 92 | <Tooltip> | |
| 93 | <TooltipTrigger | |
| 94 | render={ | |
| 95 | <button | |
| 96 | className={cn( | |
| 97 | "relative -mr-1 flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 opacity-64 transition-[background-color,color,opacity] hover:bg-background/70 hover:text-foreground hover:opacity-100 group-hover:opacity-100 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11", | |
| 98 | props.isFavorite && "text-foreground opacity-100", | |
| 99 | )} | |
| 100 | onClick={(event) => { | |
| 101 | event.stopPropagation(); | |
| 102 | props.onToggleFavorite(); | |
| 103 | }} | |
| 104 | onKeyDown={(event) => { | |
| 105 | event.stopPropagation(); | |
| 106 | }} | |
| 107 | disabled={Boolean(props.disabledReason)} | |
| 108 | type="button" | |
| 109 | aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} | |
| 110 | > | |
| 111 | <StarIcon | |
| 112 | className={cn( | |
| 113 | "size-3.5 sm:size-3", | |
| 114 | props.isFavorite && "fill-current text-yellow-500", | |
| 115 | )} | |
| 116 | /> | |
| 117 | </button> | |
| 118 | } | |
| 119 | /> | |
| 120 | <TooltipPopup side="top" align="center"> | |
| 121 | {props.isFavorite ? "Remove from favorites" : "Add to favorites"} | |
| 122 | </TooltipPopup> | |
| 123 | </Tooltip> | |
| 124 | </div> | |
| 121 | 125 | </ComboboxItem> |
| 122 | 126 | ); |
| 127 | ||
| 128 | if (!props.disabledReason) { | |
| 129 | return row; | |
| 130 | } | |
| 131 | ||
| 132 | return ( | |
| 133 | <Tooltip> | |
| 134 | <TooltipTrigger render={row} /> | |
| 135 | <TooltipPopup side="left" align="center" className="max-w-64 text-balance leading-snug"> | |
| 136 | {props.disabledReason} | |
| 137 | </TooltipPopup> | |
| 138 | </Tooltip> | |
| 139 | ); | |
| 123 | 140 | }); |
apps/web/src/components/chat/ModelPickerContent.tsx+173-154| ... | ... | @@ -4,14 +4,15 @@ import { |
| 4 | 4 | type ResolvedKeybindingsConfig, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 7 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 7 | 8 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 8 | 9 | import { SearchIcon } from "lucide-react"; |
| 9 | 10 | import { ModelListRow } from "./ModelListRow"; |
| 10 | 11 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; |
| 11 | 12 | import { isModelPickerNewModel } from "./modelPickerModelHighlights"; |
| 12 | 13 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; |
| 13 | import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; | |
| 14 | import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | |
| 14 | import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox"; | |
| 15 | import { ModelEsque } from "./providerIconUtils"; | |
| 15 | 16 | import { |
| 16 | 17 | modelPickerJumpCommandForIndex, |
| 17 | 18 | modelPickerJumpIndexFromCommand, |
| ... | ... | @@ -81,17 +82,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 81 | 82 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; |
| 82 | 83 | terminalOpen: boolean; |
| 83 | 84 | onRequestClose?: () => void; |
| 85 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | |
| 84 | 86 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 85 | 87 | }) { |
| 86 | 88 | const { |
| 87 | 89 | keybindings: providedKeybindings, |
| 88 | 90 | modelOptionsByInstance, |
| 89 | 91 | instanceEntries, |
| 92 | getModelDisabledReason, | |
| 90 | 93 | onInstanceModelChange, |
| 91 | 94 | } = props; |
| 92 | 95 | const [searchQuery, setSearchQuery] = useState(""); |
| 96 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); | |
| 97 | const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); | |
| 93 | 98 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 94 | const listRegionRef = useRef<HTMLDivElement>(null); | |
| 99 | const modelListRef = useRef<LegendListRef | null>(null); | |
| 95 | 100 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 96 | 101 | const favorites = useSettings((s) => s.favorites ?? []); |
| 97 | 102 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">( |
| ... | ... | @@ -213,16 +218,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 213 | 218 | |
| 214 | 219 | const isLocked = props.lockedProvider !== null; |
| 215 | 220 | const isSearching = searchQuery.trim().length > 0; |
| 216 | const lockedInstanceEntries = useMemo( | |
| 217 | () => | |
| 218 | props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], | |
| 219 | [instanceEntries, matchesLockedProvider, props.lockedProvider], | |
| 220 | ); | |
| 221 | const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; | |
| 222 | const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); | |
| 223 | const sidebarInstanceEntries = showLockedInstanceSidebar | |
| 224 | ? lockedInstanceEntries | |
| 225 | : instanceEntries; | |
| 221 | const lockedDisabledInstanceIds = useMemo(() => { | |
| 222 | if (!isLocked) { | |
| 223 | return undefined; | |
| 224 | } | |
| 225 | const disabled = new Set<ProviderInstanceId>(); | |
| 226 | for (const entry of instanceEntries) { | |
| 227 | if (!matchesLockedProvider(entry)) { | |
| 228 | disabled.add(entry.instanceId); | |
| 229 | } | |
| 230 | } | |
| 231 | return disabled; | |
| 232 | }, [instanceEntries, isLocked, matchesLockedProvider]); | |
| 233 | const sidebarInstanceEntries = useMemo(() => { | |
| 234 | if (!isLocked) { | |
| 235 | return instanceEntries; | |
| 236 | } | |
| 237 | const available: ProviderInstanceEntry[] = []; | |
| 238 | const disabled: ProviderInstanceEntry[] = []; | |
| 239 | for (const entry of instanceEntries) { | |
| 240 | if (matchesLockedProvider(entry)) { | |
| 241 | available.push(entry); | |
| 242 | } else { | |
| 243 | disabled.push(entry); | |
| 244 | } | |
| 245 | } | |
| 246 | return [...available, ...disabled]; | |
| 247 | }, [instanceEntries, isLocked, matchesLockedProvider]); | |
| 248 | const showSidebar = !isSearching && sidebarInstanceEntries.length > 0; | |
| 226 | 249 | const instanceOrder = useMemo( |
| 227 | 250 | () => instanceEntries.map((entry) => entry.instanceId), |
| 228 | 251 | [instanceEntries], |
| ... | ... | @@ -308,7 +331,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 308 | 331 | |
| 309 | 332 | if (props.lockedProvider !== null) { |
| 310 | 333 | result = result.filter((m) => matchesLockedProvider(m)); |
| 311 | if (showLockedInstanceSidebar) { | |
| 334 | if (selectedInstanceId === "favorites") { | |
| 335 | result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); | |
| 336 | } else { | |
| 312 | 337 | result = result.filter((m) => m.instanceId === selectedInstanceId); |
| 313 | 338 | } |
| 314 | 339 | } else if (selectedInstanceId === "favorites") { |
| ... | ... | @@ -329,12 +354,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 329 | 354 | matchesLockedProvider, |
| 330 | 355 | props.lockedProvider, |
| 331 | 356 | searchQuery, |
| 332 | showLockedInstanceSidebar, | |
| 333 | 357 | selectedInstanceId, |
| 334 | 358 | ]); |
| 335 | 359 | |
| 336 | 360 | const handleModelSelect = useCallback( |
| 337 | 361 | (modelSlug: string, instanceId: ProviderInstanceId) => { |
| 362 | if (getModelDisabledReason?.(instanceId, modelSlug)) { | |
| 363 | return; | |
| 364 | } | |
| 338 | 365 | const options = modelOptionsByInstance.get(instanceId); |
| 339 | 366 | if (!options) { |
| 340 | 367 | return; |
| ... | ... | @@ -351,7 +378,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 351 | 378 | onInstanceModelChange(instanceId, resolvedModel); |
| 352 | 379 | } |
| 353 | 380 | }, |
| 354 | [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange], | |
| 381 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], | |
| 355 | 382 | ); |
| 356 | 383 | |
| 357 | 384 | const toggleFavorite = useCallback( |
| ... | ... | @@ -368,39 +395,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 368 | 395 | [favorites, updateSettings], |
| 369 | 396 | ); |
| 370 | 397 | |
| 371 | const LockedProviderIcon = | |
| 372 | isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; | |
| 373 | // Header label for locked mode. Use the active instance's displayName | |
| 374 | // when the lock narrows to exactly one instance (so "Codex Personal" | |
| 375 | // shows instead of the generic driver label); fall back to the first | |
| 376 | // matching entry otherwise. | |
| 377 | const lockedHeaderLabel = useMemo(() => { | |
| 378 | if (!isLocked || !props.lockedProvider) return null; | |
| 379 | const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); | |
| 380 | if (matches.length === 0) return null; | |
| 381 | const active = matches.find((entry) => entry.instanceId === props.activeInstanceId); | |
| 382 | return (active ?? matches[0])?.displayName ?? null; | |
| 383 | }, [ | |
| 384 | isLocked, | |
| 385 | matchesLockedProvider, | |
| 386 | props.lockedProvider, | |
| 387 | props.activeInstanceId, | |
| 388 | instanceEntries, | |
| 389 | ]); | |
| 390 | 398 | const modelJumpCommandByKey = useMemo(() => { |
| 391 | 399 | const mapping = new Map< |
| 392 | 400 | string, |
| 393 | 401 | NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>> |
| 394 | 402 | >(); |
| 395 | for (const [visibleModelIndex, model] of filteredModels.entries()) { | |
| 396 | const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); | |
| 403 | let selectableModelIndex = 0; | |
| 404 | for (const model of filteredModels) { | |
| 405 | if (getModelDisabledReason?.(model.instanceId, model.slug)) { | |
| 406 | continue; | |
| 407 | } | |
| 408 | const jumpCommand = modelPickerJumpCommandForIndex(selectableModelIndex); | |
| 397 | 409 | if (!jumpCommand) { |
| 398 | 410 | return mapping; |
| 399 | 411 | } |
| 400 | 412 | mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); |
| 413 | selectableModelIndex += 1; | |
| 401 | 414 | } |
| 402 | 415 | return mapping; |
| 403 | }, [filteredModels]); | |
| 416 | }, [filteredModels, getModelDisabledReason]); | |
| 404 | 417 | const modelJumpModelKeys = useMemo( |
| 405 | 418 | () => [...modelJumpCommandByKey.keys()], |
| 406 | 419 | [modelJumpCommandByKey], |
| ... | ... | @@ -418,6 +431,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 418 | 431 | new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), |
| 419 | 432 | [filteredModels], |
| 420 | 433 | ); |
| 434 | const updateModelListScrollFades = useCallback(() => { | |
| 435 | const scrollElement = modelListRef.current?.getScrollableNode(); | |
| 436 | if (!(scrollElement instanceof HTMLElement)) { | |
| 437 | return; | |
| 438 | } | |
| 439 | const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); | |
| 440 | setShowTopScrollFade(scrollElement.scrollTop > 1); | |
| 441 | setShowBottomScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); | |
| 442 | }, []); | |
| 421 | 443 | const modelJumpShortcutContext = useMemo( |
| 422 | 444 | () => |
| 423 | 445 | ({ |
| ... | ... | @@ -478,72 +500,40 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 478 | 500 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); |
| 479 | 501 | |
| 480 | 502 | useLayoutEffect(() => { |
| 481 | const listRegion = listRegionRef.current; | |
| 482 | if (!listRegion) { | |
| 483 | return; | |
| 484 | } | |
| 485 | ||
| 486 | let cancelled = false; | |
| 487 | let frame = 0; | |
| 503 | setShowTopScrollFade(false); | |
| 504 | setShowBottomScrollFade(filteredModelKeys.length > 5); | |
| 488 | 505 | let nestedFrame = 0; |
| 489 | let timeout = 0; | |
| 490 | ||
| 491 | const measureScrollArea = () => { | |
| 492 | if (cancelled) { | |
| 493 | return; | |
| 494 | } | |
| 495 | const viewport = listRegion.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'); | |
| 496 | if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { | |
| 497 | return; | |
| 498 | } | |
| 499 | const originalScrollTop = viewport.scrollTop; | |
| 500 | const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; | |
| 501 | if (maxScrollTop <= 0) { | |
| 502 | return; | |
| 503 | } | |
| 504 | viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); | |
| 505 | viewport.scrollTop = originalScrollTop; | |
| 506 | }; | |
| 507 | ||
| 508 | queueMicrotask(measureScrollArea); | |
| 509 | frame = window.requestAnimationFrame(() => { | |
| 510 | measureScrollArea(); | |
| 511 | nestedFrame = window.requestAnimationFrame(measureScrollArea); | |
| 506 | const frame = window.requestAnimationFrame(() => { | |
| 507 | updateModelListScrollFades(); | |
| 508 | nestedFrame = window.requestAnimationFrame(updateModelListScrollFades); | |
| 512 | 509 | }); |
| 513 | timeout = window.setTimeout(measureScrollArea, 0); | |
| 514 | ||
| 515 | 510 | return () => { |
| 516 | cancelled = true; | |
| 517 | 511 | window.cancelAnimationFrame(frame); |
| 518 | 512 | window.cancelAnimationFrame(nestedFrame); |
| 519 | window.clearTimeout(timeout); | |
| 520 | 513 | }; |
| 521 | }, [filteredModelKeys]); | |
| 514 | }, [filteredModelKeys, updateModelListScrollFades]); | |
| 522 | 515 | |
| 523 | 516 | return ( |
| 524 | 517 | <TooltipProvider delay={0}> |
| 525 | 518 | <div |
| 526 | className={cn( | |
| 527 | "relative flex h-screen max-h-96 w-screen max-w-100 overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | |
| 528 | isLocked && !showLockedInstanceSidebar ? "flex-col" : "flex-row", | |
| 529 | )} | |
| 519 | className="relative flex h-screen max-h-96 w-screen max-w-100 flex-row overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]" | |
| 520 | data-model-picker-content="true" | |
| 530 | 521 | > |
| 531 | {/* Locked provider header (only shown in locked mode) */} | |
| 532 | {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && ( | |
| 533 | <div className="flex items-center gap-2 px-4 py-3 border-b"> | |
| 534 | <LockedProviderIcon className="size-5 shrink-0" /> | |
| 535 | <span className="font-medium text-sm">{lockedHeaderLabel}</span> | |
| 536 | </div> | |
| 537 | )} | |
| 538 | ||
| 539 | {/* Sidebar (only in unlocked mode) */} | |
| 522 | {/* Sidebar */} | |
| 540 | 523 | {showSidebar && ( |
| 541 | 524 | <ModelPickerSidebar |
| 542 | 525 | selectedInstanceId={selectedInstanceId} |
| 543 | 526 | onSelectInstance={handleSelectInstance} |
| 544 | 527 | instanceEntries={sidebarInstanceEntries} |
| 545 | showFavorites={!isLocked} | |
| 546 | showComingSoon={!isLocked} | |
| 528 | showFavorites | |
| 529 | showComingSoon | |
| 530 | {...(lockedDisabledInstanceIds | |
| 531 | ? { | |
| 532 | disabledInstanceIds: lockedDisabledInstanceIds, | |
| 533 | getDisabledInstanceTooltip: (entry: ProviderInstanceEntry) => | |
| 534 | `${entry.displayName} is unavailable in this thread. Start a new thread to switch providers.`, | |
| 535 | } | |
| 536 | : {})} | |
| 547 | 537 | /> |
| 548 | 538 | )} |
| 549 | 539 | |
| ... | ... | @@ -555,9 +545,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 555 | 545 | filter={null} |
| 556 | 546 | autoHighlight |
| 557 | 547 | open |
| 548 | virtualized | |
| 558 | 549 | value={`${props.activeInstanceId}:${props.model}`} |
| 559 | onItemHighlighted={(modelKey) => { | |
| 550 | onItemHighlighted={(modelKey, eventDetails) => { | |
| 560 | 551 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; |
| 552 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { | |
| 553 | modelListRef.current?.scrollIndexIntoView?.({ | |
| 554 | index: eventDetails.index, | |
| 555 | animated: false, | |
| 556 | }); | |
| 557 | } | |
| 561 | 558 | }} |
| 562 | 559 | onValueChange={(modelKey) => { |
| 563 | 560 | if (typeof modelKey !== "string") { |
| ... | ... | @@ -569,79 +566,101 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 569 | 566 | > |
| 570 | 567 | <div |
| 571 | 568 | className={cn( |
| 572 | "flex min-h-0 flex-1 flex-col overflow-hidden", | |
| 573 | isLocked && !showLockedInstanceSidebar ? "min-w-0" : showSidebar && "border-l", | |
| 569 | "flex min-h-0 flex-1 flex-col overflow-hidden bg-muted/40", | |
| 570 | showSidebar && "border-l", | |
| 574 | 571 | )} |
| 575 | 572 | > |
| 576 | 573 | {/* Search bar */} |
| 577 | <div className="border-b px-3 py-2"> | |
| 578 | <ComboboxInput | |
| 579 | ref={searchInputRef} | |
| 580 | className="[&_input]:font-sans rounded-md" | |
| 581 | inputClassName="border-0 shadow-none ring-0 focus-visible:ring-0" | |
| 582 | placeholder="Search models..." | |
| 583 | showTrigger={false} | |
| 584 | startAddon={<SearchIcon className="size-4 shrink-0 text-muted-foreground/50" />} | |
| 585 | value={searchQuery} | |
| 586 | onChange={(e) => setSearchQuery(e.target.value)} | |
| 587 | onKeyDown={(e) => { | |
| 588 | if (e.key === "Escape") { | |
| 589 | e.preventDefault(); | |
| 590 | e.stopPropagation(); | |
| 591 | props.onRequestClose?.(); | |
| 592 | return; | |
| 574 | <div className="px-4 pt-2.5"> | |
| 575 | <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring"> | |
| 576 | <ComboboxInput | |
| 577 | ref={searchInputRef} | |
| 578 | className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" | |
| 579 | inputClassName="rounded-none bg-transparent text-sm" | |
| 580 | placeholder="Search models..." | |
| 581 | showTrigger={false} | |
| 582 | startAddon={ | |
| 583 | <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> | |
| 593 | 584 | } |
| 594 | if (e.key === "Enter" && highlightedModelKeyRef.current) { | |
| 595 | ( | |
| 596 | e as typeof e & { preventBaseUIHandler?: () => void } | |
| 597 | ).preventBaseUIHandler?.(); | |
| 598 | e.preventDefault(); | |
| 585 | value={searchQuery} | |
| 586 | onChange={(e) => setSearchQuery(e.target.value)} | |
| 587 | onKeyDown={(e) => { | |
| 588 | if (e.key === "Escape") { | |
| 589 | e.preventDefault(); | |
| 590 | e.stopPropagation(); | |
| 591 | props.onRequestClose?.(); | |
| 592 | return; | |
| 593 | } | |
| 594 | if (e.key === "Enter" && highlightedModelKeyRef.current) { | |
| 595 | ( | |
| 596 | e as typeof e & { preventBaseUIHandler?: () => void } | |
| 597 | ).preventBaseUIHandler?.(); | |
| 598 | e.preventDefault(); | |
| 599 | e.stopPropagation(); | |
| 600 | const { instanceId, slug } = splitInstanceModelKey( | |
| 601 | highlightedModelKeyRef.current, | |
| 602 | ); | |
| 603 | handleModelSelect(slug, instanceId); | |
| 604 | return; | |
| 605 | } | |
| 599 | 606 | e.stopPropagation(); |
| 600 | const { instanceId, slug } = splitInstanceModelKey( | |
| 601 | highlightedModelKeyRef.current, | |
| 602 | ); | |
| 603 | handleModelSelect(slug, instanceId); | |
| 604 | return; | |
| 605 | } | |
| 606 | e.stopPropagation(); | |
| 607 | }} | |
| 608 | onMouseDown={(e) => e.stopPropagation()} | |
| 609 | onTouchStart={(e) => e.stopPropagation()} | |
| 610 | size="sm" | |
| 611 | /> | |
| 607 | }} | |
| 608 | onMouseDown={(e) => e.stopPropagation()} | |
| 609 | onTouchStart={(e) => e.stopPropagation()} | |
| 610 | size="sm" | |
| 611 | unstyled | |
| 612 | /> | |
| 613 | </div> | |
| 612 | 614 | </div> |
| 613 | 615 | |
| 614 | 616 | {/* Model list */} |
| 615 | <div | |
| 616 | ref={listRegionRef} | |
| 617 | className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40" | |
| 618 | > | |
| 619 | <ComboboxList className="model-picker-list size-full divide-y px-2 py-1"> | |
| 620 | {filteredModelKeys.map((modelKey, index) => { | |
| 621 | const model = filteredModelByKey.get(modelKey); | |
| 622 | if (!model) { | |
| 623 | return null; | |
| 624 | } | |
| 625 | return ( | |
| 626 | <ModelListRow | |
| 627 | key={modelKey} | |
| 628 | index={index} | |
| 629 | model={model} | |
| 630 | instanceId={model.instanceId} | |
| 631 | driverKind={model.driverKind} | |
| 632 | providerDisplayName={model.instanceDisplayName} | |
| 633 | providerAccentColor={model.instanceAccentColor} | |
| 634 | isFavorite={favoritesSet.has(modelKey)} | |
| 635 | showProvider={!isLocked || showLockedInstanceSidebar} | |
| 636 | preferShortName={!isLocked} | |
| 637 | useTriggerLabel={isLocked && !showLockedInstanceSidebar} | |
| 638 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} | |
| 639 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} | |
| 640 | onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} | |
| 641 | /> | |
| 642 | ); | |
| 643 | })} | |
| 644 | </ComboboxList> | |
| 617 | <div className="relative min-h-0 flex-1 overflow-hidden"> | |
| 618 | <ComboboxListVirtualized className="model-picker-list size-full min-w-0 p-0"> | |
| 619 | <LegendList<string> | |
| 620 | ref={modelListRef} | |
| 621 | data={filteredModelKeys} | |
| 622 | extraData={favoritesSet} | |
| 623 | keyExtractor={(modelKey) => modelKey} | |
| 624 | renderItem={({ item: modelKey, index }) => { | |
| 625 | const model = filteredModelByKey.get(modelKey); | |
| 626 | if (!model) { | |
| 627 | return null; | |
| 628 | } | |
| 629 | const disabledReason = | |
| 630 | getModelDisabledReason?.(model.instanceId, model.slug) ?? null; | |
| 631 | return ( | |
| 632 | <ModelListRow | |
| 633 | key={modelKey} | |
| 634 | index={index} | |
| 635 | model={model} | |
| 636 | instanceId={model.instanceId} | |
| 637 | driverKind={model.driverKind} | |
| 638 | providerDisplayName={model.instanceDisplayName} | |
| 639 | providerAccentColor={model.instanceAccentColor} | |
| 640 | isFavorite={favoritesSet.has(modelKey)} | |
| 641 | isSelected={modelKey === `${props.activeInstanceId}:${props.model}`} | |
| 642 | showProvider | |
| 643 | preferShortName={!isLocked} | |
| 644 | useTriggerLabel={false} | |
| 645 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} | |
| 646 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} | |
| 647 | disabledReason={disabledReason} | |
| 648 | onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} | |
| 649 | /> | |
| 650 | ); | |
| 651 | }} | |
| 652 | estimatedItemSize={60} | |
| 653 | drawDistance={480} | |
| 654 | recycleItems | |
| 655 | onLayout={updateModelListScrollFades} | |
| 656 | onScroll={updateModelListScrollFades} | |
| 657 | className={cn( | |
| 658 | "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", | |
| 659 | showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", | |
| 660 | showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", | |
| 661 | )} | |
| 662 | /> | |
| 663 | </ComboboxListVirtualized> | |
| 645 | 664 | </div> |
| 646 | 665 | <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug"> |
| 647 | 666 | No models found |
apps/web/src/components/chat/ModelPickerSidebar.tsx+227-162| ... | ... | @@ -1,9 +1,8 @@ |
| 1 | 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo, useMemo } from "react"; | |
| 2 | import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; |
| 4 | 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; |
| 5 | 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | import { ScrollArea } from "../ui/scroll-area"; | |
| 7 | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 7 | import { cn } from "~/lib/utils"; |
| 9 | 8 | import type { ProviderInstanceEntry } from "../../providerInstances"; |
| ... | ... | @@ -30,9 +29,8 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { |
| 30 | 29 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; |
| 31 | 30 | } |
| 32 | 31 | |
| 33 | const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; | |
| 34 | 32 | const SELECTED_INDICATOR_CLASS = |
| 35 | "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; | |
| 33 | "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; | |
| 36 | 34 | const BADGE_BASE_CLASS = |
| 37 | 35 | "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; |
| 38 | 36 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; |
| ... | ... | @@ -40,6 +38,7 @@ const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; |
| 40 | 38 | |
| 41 | 39 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 42 | 40 | const PICKER_TOOLTIP_SIDE = "left" as const; |
| 41 | const PICKER_TOOLTIP_SIDE_OFFSET = 8; | |
| 43 | 42 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; |
| 44 | 43 | |
| 45 | 44 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| ... | ... | @@ -56,6 +55,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 56 | 55 | showFavorites?: boolean; |
| 57 | 56 | /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ |
| 58 | 57 | showComingSoon?: boolean; |
| 58 | /** Instance ids shown in the rail but unavailable for the current picker context. */ | |
| 59 | disabledInstanceIds?: ReadonlySet<ProviderInstanceId>; | |
| 60 | getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; | |
| 59 | 61 | /** |
| 60 | 62 | * Instance id values that should render the "new" sparkle badge. Callers |
| 61 | 63 | * pass the subset of default built-in ids they want flagged (custom |
| ... | ... | @@ -68,6 +70,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 68 | 70 | }; |
| 69 | 71 | const showFavorites = props.showFavorites ?? true; |
| 70 | 72 | const showComingSoon = props.showComingSoon ?? true; |
| 73 | const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null); | |
| 74 | const sidebarContentRef = useRef<HTMLDivElement>(null); | |
| 75 | const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null); | |
| 71 | 76 | const duplicateDriverCounts = useMemo(() => { |
| 72 | 77 | const counts = new Map<string, number>(); |
| 73 | 78 | for (const entry of props.instanceEntries) { |
| ... | ... | @@ -76,188 +81,248 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 76 | 81 | return counts; |
| 77 | 82 | }, [props.instanceEntries]); |
| 78 | 83 | |
| 84 | useLayoutEffect(() => { | |
| 85 | const content = sidebarContentRef.current; | |
| 86 | if (!content) { | |
| 87 | return; | |
| 88 | } | |
| 89 | const selectedButton = Array.from( | |
| 90 | content.querySelectorAll<HTMLElement>("[data-model-picker-provider]"), | |
| 91 | ).find((button) => button.dataset.modelPickerProvider === props.selectedInstanceId); | |
| 92 | if (!selectedButton) { | |
| 93 | setSelectedIndicatorTop(null); | |
| 94 | return; | |
| 95 | } | |
| 96 | const contentRect = content.getBoundingClientRect(); | |
| 97 | const selectedButtonRect = selectedButton.getBoundingClientRect(); | |
| 98 | setSelectedIndicatorTop( | |
| 99 | selectedButtonRect.top - | |
| 100 | contentRect.top + | |
| 101 | content.scrollTop + | |
| 102 | selectedButtonRect.height / 2 - | |
| 103 | 10, | |
| 104 | ); | |
| 105 | }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); | |
| 106 | ||
| 79 | 107 | return ( |
| 80 | <ScrollArea | |
| 81 | hideScrollbars | |
| 82 | scrollFade | |
| 83 | className="w-12 shrink-0 border-r bg-muted/30" | |
| 108 | <div | |
| 109 | className="w-12 shrink-0 overflow-hidden border-r bg-muted/30" | |
| 84 | 110 | data-model-picker-sidebar="true" |
| 85 | 111 | > |
| 86 | <div className="flex min-h-full flex-col gap-1 p-1"> | |
| 87 | {/* Favorites section */} | |
| 88 | {showFavorites ? ( | |
| 89 | <div className="pb-1 mb-1 border-b"> | |
| 90 | <div className="relative w-full"> | |
| 91 | {props.selectedInstanceId === "favorites" && ( | |
| 92 | <div className={SELECTED_INDICATOR_CLASS} /> | |
| 112 | <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 113 | <div | |
| 114 | ref={sidebarContentRef} | |
| 115 | className="relative flex min-h-full flex-col gap-1 px-1 pb-1 pt-0.5" | |
| 116 | > | |
| 117 | {selectedIndicatorTop !== null ? ( | |
| 118 | <div | |
| 119 | data-model-picker-selected-indicator="true" | |
| 120 | className={cn( | |
| 121 | SELECTED_INDICATOR_CLASS, | |
| 122 | "right-0 translate-y-0 transition-[top] duration-200 ease-out", | |
| 93 | 123 | )} |
| 124 | style={{ top: selectedIndicatorTop }} | |
| 125 | /> | |
| 126 | ) : null} | |
| 127 | {/* Favorites section */} | |
| 128 | {showFavorites ? ( | |
| 129 | <div className="mb-1 border-b pb-1"> | |
| 130 | <div className="relative w-full"> | |
| 131 | <Tooltip> | |
| 132 | <TooltipTrigger | |
| 133 | render={ | |
| 134 | <button | |
| 135 | className={cn( | |
| 136 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-muted", | |
| 137 | )} | |
| 138 | onClick={() => handleSelect("favorites")} | |
| 139 | type="button" | |
| 140 | data-model-picker-provider="favorites" | |
| 141 | aria-label="Favorites" | |
| 142 | > | |
| 143 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | |
| 144 | </button> | |
| 145 | } | |
| 146 | /> | |
| 147 | <TooltipPopup | |
| 148 | side={PICKER_TOOLTIP_SIDE} | |
| 149 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 150 | align="center" | |
| 151 | className={PICKER_TOOLTIP_CLASS} | |
| 152 | > | |
| 153 | Favorites | |
| 154 | </TooltipPopup> | |
| 155 | </Tooltip> | |
| 156 | </div> | |
| 157 | </div> | |
| 158 | ) : null} | |
| 159 | ||
| 160 | {/* Instance buttons (one per configured instance — built-in + custom) */} | |
| 161 | {props.instanceEntries.map((entry) => { | |
| 162 | const isUnavailable = !entry.isAvailable || entry.status !== "ready"; | |
| 163 | const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; | |
| 164 | const isDisabled = isUnavailable || isContextDisabled; | |
| 165 | const isSelected = props.selectedInstanceId === entry.instanceId; | |
| 166 | const isHovered = hoveredInstanceId === entry.instanceId; | |
| 167 | const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; | |
| 168 | const showInstanceBadge = | |
| 169 | Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; | |
| 170 | ||
| 171 | const tooltip = isUnavailable | |
| 172 | ? describeUnavailableInstance(entry) | |
| 173 | : isContextDisabled | |
| 174 | ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName) | |
| 175 | : showNewBadge | |
| 176 | ? `${entry.displayName} — New` | |
| 177 | : entry.displayName; | |
| 178 | ||
| 179 | const button = ( | |
| 180 | <button | |
| 181 | data-model-picker-provider={entry.instanceId} | |
| 182 | className={cn( | |
| 183 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-muted", | |
| 184 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | |
| 185 | )} | |
| 186 | data-provider-accent-color={entry.accentColor} | |
| 187 | onClick={() => !isDisabled && handleSelect(entry.instanceId)} | |
| 188 | onMouseEnter={() => setHoveredInstanceId(entry.instanceId)} | |
| 189 | onMouseLeave={() => | |
| 190 | setHoveredInstanceId((current) => (current === entry.instanceId ? null : current)) | |
| 191 | } | |
| 192 | onFocus={() => setHoveredInstanceId(entry.instanceId)} | |
| 193 | onBlur={() => | |
| 194 | setHoveredInstanceId((current) => (current === entry.instanceId ? null : current)) | |
| 195 | } | |
| 196 | disabled={isDisabled} | |
| 197 | type="button" | |
| 198 | aria-label={ | |
| 199 | isDisabled | |
| 200 | ? tooltip | |
| 201 | : showNewBadge | |
| 202 | ? `${entry.displayName}, new` | |
| 203 | : entry.displayName | |
| 204 | } | |
| 205 | > | |
| 206 | <ProviderInstanceIcon | |
| 207 | driverKind={entry.driverKind} | |
| 208 | displayName={entry.displayName} | |
| 209 | accentColor={entry.accentColor} | |
| 210 | showBadge={showInstanceBadge} | |
| 211 | className="size-6" | |
| 212 | iconClassName="size-5" | |
| 213 | indicatorBackground={ | |
| 214 | isHovered && !isDisabled | |
| 215 | ? "var(--muted)" | |
| 216 | : isSelected | |
| 217 | ? "var(--background)" | |
| 218 | : "color-mix(in oklab, var(--muted) 30%, transparent)" | |
| 219 | } | |
| 220 | {...(entry.accentColor | |
| 221 | ? { badgeClassName: "h-3 min-w-3 px-0.5 text-[7px]" } | |
| 222 | : {})} | |
| 223 | /> | |
| 224 | {showNewBadge ? ( | |
| 225 | <span className={NEW_BADGE_CLASS} aria-hidden> | |
| 226 | <SparklesIcon className="size-2" /> | |
| 227 | </span> | |
| 228 | ) : null} | |
| 229 | </button> | |
| 230 | ); | |
| 231 | ||
| 232 | const trigger = isDisabled ? ( | |
| 233 | <span className="relative block w-full">{button}</span> | |
| 234 | ) : ( | |
| 235 | button | |
| 236 | ); | |
| 237 | ||
| 238 | return ( | |
| 239 | <div key={entry.instanceId} className="relative w-full"> | |
| 240 | <Tooltip> | |
| 241 | <TooltipTrigger render={trigger} /> | |
| 242 | <TooltipPopup | |
| 243 | side={PICKER_TOOLTIP_SIDE} | |
| 244 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 245 | align="center" | |
| 246 | className={PICKER_TOOLTIP_CLASS} | |
| 247 | > | |
| 248 | {tooltip} | |
| 249 | </TooltipPopup> | |
| 250 | </Tooltip> | |
| 251 | </div> | |
| 252 | ); | |
| 253 | })} | |
| 254 | ||
| 255 | {showComingSoon ? ( | |
| 256 | <> | |
| 257 | {/* Gemini button (coming soon) */} | |
| 94 | 258 | <Tooltip> |
| 95 | 259 | <TooltipTrigger |
| 96 | 260 | render={ |
| 97 | <button | |
| 98 | className={cn( | |
| 99 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | |
| 100 | props.selectedInstanceId === "favorites" && SELECTED_BUTTON_CLASS, | |
| 101 | )} | |
| 102 | onClick={() => handleSelect("favorites")} | |
| 103 | type="button" | |
| 104 | data-model-picker-provider="favorites" | |
| 105 | aria-label="Favorites" | |
| 106 | > | |
| 107 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | |
| 108 | </button> | |
| 261 | <span className="relative block w-full"> | |
| 262 | <button | |
| 263 | className={cn( | |
| 264 | "relative isolate flex w-full aspect-square items-center justify-center rounded-md opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | |
| 265 | )} | |
| 266 | disabled | |
| 267 | type="button" | |
| 268 | data-model-picker-provider="gemini-coming-soon" | |
| 269 | aria-label="Gemini — coming soon" | |
| 270 | > | |
| 271 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> | |
| 272 | <span className={SOON_BADGE_CLASS} aria-hidden> | |
| 273 | <Clock3Icon className="size-2" /> | |
| 274 | </span> | |
| 275 | </button> | |
| 276 | </span> | |
| 109 | 277 | } |
| 110 | 278 | /> |
| 111 | 279 | <TooltipPopup |
| 112 | 280 | side={PICKER_TOOLTIP_SIDE} |
| 281 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 113 | 282 | align="center" |
| 114 | 283 | className={PICKER_TOOLTIP_CLASS} |
| 115 | 284 | > |
| 116 | Favorites | |
| 285 | Gemini — Coming soon | |
| 117 | 286 | </TooltipPopup> |
| 118 | 287 | </Tooltip> |
| 119 | </div> | |
| 120 | </div> | |
| 121 | ) : null} | |
| 122 | ||
| 123 | {/* Instance buttons (one per configured instance — built-in + custom) */} | |
| 124 | {props.instanceEntries.map((entry) => { | |
| 125 | const isDisabled = !entry.isAvailable || entry.status !== "ready"; | |
| 126 | const isSelected = props.selectedInstanceId === entry.instanceId; | |
| 127 | const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; | |
| 128 | const showInstanceBadge = | |
| 129 | Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; | |
| 130 | ||
| 131 | const tooltip = isDisabled | |
| 132 | ? describeUnavailableInstance(entry) | |
| 133 | : showNewBadge | |
| 134 | ? `${entry.displayName} — New` | |
| 135 | : entry.displayName; | |
| 136 | ||
| 137 | const button = ( | |
| 138 | <button | |
| 139 | data-model-picker-provider={entry.instanceId} | |
| 140 | className={cn( | |
| 141 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | |
| 142 | isSelected && SELECTED_BUTTON_CLASS, | |
| 143 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | |
| 144 | )} | |
| 145 | data-provider-accent-color={entry.accentColor} | |
| 146 | onClick={() => !isDisabled && handleSelect(entry.instanceId)} | |
| 147 | disabled={isDisabled} | |
| 148 | type="button" | |
| 149 | aria-label={ | |
| 150 | isDisabled | |
| 151 | ? tooltip | |
| 152 | : showNewBadge | |
| 153 | ? `${entry.displayName}, new` | |
| 154 | : entry.displayName | |
| 155 | } | |
| 156 | > | |
| 157 | <ProviderInstanceIcon | |
| 158 | driverKind={entry.driverKind} | |
| 159 | displayName={entry.displayName} | |
| 160 | accentColor={entry.accentColor} | |
| 161 | showBadge={showInstanceBadge} | |
| 162 | className="size-6" | |
| 163 | iconClassName="size-5" | |
| 164 | /> | |
| 165 | {showNewBadge ? ( | |
| 166 | <span className={NEW_BADGE_CLASS} aria-hidden> | |
| 167 | <SparklesIcon className="size-2" /> | |
| 168 | </span> | |
| 169 | ) : null} | |
| 170 | </button> | |
| 171 | ); | |
| 172 | ||
| 173 | const trigger = isDisabled ? ( | |
| 174 | <span className="relative block w-full">{button}</span> | |
| 175 | ) : ( | |
| 176 | button | |
| 177 | ); | |
| 178 | ||
| 179 | return ( | |
| 180 | <div key={entry.instanceId} className="relative w-full"> | |
| 181 | {isSelected && <div className={SELECTED_INDICATOR_CLASS} />} | |
| 288 | {/* Github Copilot button (coming soon) */} | |
| 182 | 289 | <Tooltip> |
| 183 | <TooltipTrigger render={trigger} /> | |
| 290 | <TooltipTrigger | |
| 291 | render={ | |
| 292 | <span className="relative block w-full"> | |
| 293 | <button | |
| 294 | className={cn( | |
| 295 | "relative isolate flex w-full aspect-square items-center justify-center rounded-md opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | |
| 296 | )} | |
| 297 | disabled | |
| 298 | type="button" | |
| 299 | data-model-picker-provider="github-copilot-coming-soon" | |
| 300 | aria-label="Github Copilot — coming soon" | |
| 301 | > | |
| 302 | <GithubCopilotIcon | |
| 303 | className="size-5 text-muted-foreground/85" | |
| 304 | aria-hidden | |
| 305 | /> | |
| 306 | <span className={SOON_BADGE_CLASS} aria-hidden> | |
| 307 | <Clock3Icon className="size-2" /> | |
| 308 | </span> | |
| 309 | </button> | |
| 310 | </span> | |
| 311 | } | |
| 312 | /> | |
| 184 | 313 | <TooltipPopup |
| 185 | 314 | side={PICKER_TOOLTIP_SIDE} |
| 315 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 186 | 316 | align="center" |
| 187 | 317 | className={PICKER_TOOLTIP_CLASS} |
| 188 | 318 | > |
| 189 | {tooltip} | |
| 319 | Github Copilot — Coming soon | |
| 190 | 320 | </TooltipPopup> |
| 191 | 321 | </Tooltip> |
| 192 | </div> | |
| 193 | ); | |
| 194 | })} | |
| 195 | ||
| 196 | {showComingSoon ? ( | |
| 197 | <> | |
| 198 | {/* Gemini button (coming soon) */} | |
| 199 | <Tooltip> | |
| 200 | <TooltipTrigger | |
| 201 | render={ | |
| 202 | <span className="relative block w-full"> | |
| 203 | <button | |
| 204 | className={cn( | |
| 205 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | |
| 206 | )} | |
| 207 | disabled | |
| 208 | type="button" | |
| 209 | data-model-picker-provider="gemini-coming-soon" | |
| 210 | aria-label="Gemini — coming soon" | |
| 211 | > | |
| 212 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> | |
| 213 | <span className={SOON_BADGE_CLASS} aria-hidden> | |
| 214 | <Clock3Icon className="size-2" /> | |
| 215 | </span> | |
| 216 | </button> | |
| 217 | </span> | |
| 218 | } | |
| 219 | /> | |
| 220 | <TooltipPopup | |
| 221 | side={PICKER_TOOLTIP_SIDE} | |
| 222 | align="center" | |
| 223 | className={PICKER_TOOLTIP_CLASS} | |
| 224 | > | |
| 225 | Gemini — Coming soon | |
| 226 | </TooltipPopup> | |
| 227 | </Tooltip> | |
| 228 | {/* Github Copilot button (coming soon) */} | |
| 229 | <Tooltip> | |
| 230 | <TooltipTrigger | |
| 231 | render={ | |
| 232 | <span className="relative block w-full"> | |
| 233 | <button | |
| 234 | className={cn( | |
| 235 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | |
| 236 | )} | |
| 237 | disabled | |
| 238 | type="button" | |
| 239 | data-model-picker-provider="github-copilot-coming-soon" | |
| 240 | aria-label="Github Copilot — coming soon" | |
| 241 | > | |
| 242 | <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden /> | |
| 243 | <span className={SOON_BADGE_CLASS} aria-hidden> | |
| 244 | <Clock3Icon className="size-2" /> | |
| 245 | </span> | |
| 246 | </button> | |
| 247 | </span> | |
| 248 | } | |
| 249 | /> | |
| 250 | <TooltipPopup | |
| 251 | side={PICKER_TOOLTIP_SIDE} | |
| 252 | align="center" | |
| 253 | className={PICKER_TOOLTIP_CLASS} | |
| 254 | > | |
| 255 | Github Copilot — Coming soon | |
| 256 | </TooltipPopup> | |
| 257 | </Tooltip> | |
| 258 | </> | |
| 259 | ) : null} | |
| 322 | </> | |
| 323 | ) : null} | |
| 324 | </div> | |
| 260 | 325 | </div> |
| 261 | </ScrollArea> | |
| 326 | </div> | |
| 262 | 327 | ); |
| 263 | 328 | }); |
apps/web/src/components/chat/ProviderInstanceIcon.tsx+10-4| ... | ... | @@ -19,20 +19,24 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 19 | 19 | displayName: string; |
| 20 | 20 | accentColor?: string | undefined; |
| 21 | 21 | showBadge?: boolean; |
| 22 | badgeContent?: "initials" | "none"; | |
| 22 | 23 | className?: string; |
| 23 | 24 | iconClassName?: string; |
| 24 | 25 | badgeClassName?: string; |
| 25 | 26 | statusDotClassName?: string; |
| 27 | indicatorBackground?: string; | |
| 26 | 28 | }) { |
| 27 | 29 | const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| 30 | const indicatorBackground = props.indicatorBackground ?? "var(--card)"; | |
| 28 | 31 | const accentStyle = props.accentColor |
| 29 | 32 | ? ({ "--provider-accent": props.accentColor } as CSSProperties) |
| 30 | 33 | : undefined; |
| 34 | const badgeContent = props.badgeContent ?? "initials"; | |
| 31 | 35 | |
| 32 | 36 | return ( |
| 33 | 37 | <span |
| 34 | 38 | className={cn( |
| 35 | "relative isolate inline-flex shrink-0 items-center justify-center", | |
| 39 | "relative isolate inline-flex shrink-0 items-center justify-center overflow-visible", | |
| 36 | 40 | props.className, |
| 37 | 41 | )} |
| 38 | 42 | style={accentStyle} |
| ... | ... | @@ -48,24 +52,26 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 48 | 52 | {props.statusDotClassName ? ( |
| 49 | 53 | <span |
| 50 | 54 | className={cn( |
| 51 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | |
| 55 | "pointer-events-none absolute -left-0.5 -top-0.5 z-10 size-2 rounded-full", | |
| 52 | 56 | props.statusDotClassName, |
| 53 | 57 | )} |
| 58 | style={{ boxShadow: `0 0 0 2px ${indicatorBackground}` }} | |
| 54 | 59 | aria-hidden |
| 55 | 60 | /> |
| 56 | 61 | ) : null} |
| 57 | 62 | {props.showBadge ? ( |
| 58 | 63 | <span |
| 59 | 64 | className={cn( |
| 60 | "pointer-events-none absolute right-0 bottom-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full border border-background px-0.5 text-[8px] font-semibold leading-none shadow-sm", | |
| 65 | "pointer-events-none absolute right-0 bottom-0 z-10 flex h-3.5 min-w-3.5 items-center justify-center rounded-full border px-0.5 text-[8px] font-semibold leading-none shadow-sm", | |
| 61 | 66 | props.accentColor |
| 62 | 67 | ? "bg-[var(--provider-accent)] text-white" |
| 63 | 68 | : "bg-muted text-muted-foreground", |
| 64 | 69 | props.badgeClassName, |
| 65 | 70 | )} |
| 71 | style={{ borderColor: indicatorBackground }} | |
| 66 | 72 | aria-hidden |
| 67 | 73 | > |
| 68 | {providerInstanceInitials(props.displayName)} | |
| 74 | {badgeContent === "initials" ? providerInstanceInitials(props.displayName) : null} | |
| 69 | 75 | </span> |
| 70 | 76 | ) : null} |
| 71 | 77 | </span> |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+102-12| ... | ... | @@ -255,6 +255,7 @@ async function mountPicker(props: { |
| 255 | 255 | providers?: ReadonlyArray<ServerProvider>; |
| 256 | 256 | settings?: UnifiedSettings; |
| 257 | 257 | triggerVariant?: "ghost" | "outline"; |
| 258 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | |
| 258 | 259 | }) { |
| 259 | 260 | const host = document.createElement("div"); |
| 260 | 261 | document.body.append(host); |
| ... | ... | @@ -277,6 +278,9 @@ async function mountPicker(props: { |
| 277 | 278 | instanceEntries={instanceEntries} |
| 278 | 279 | modelOptionsByInstance={modelOptionsByInstance} |
| 279 | 280 | triggerVariant={props.triggerVariant} |
| 281 | {...(props.getModelDisabledReason | |
| 282 | ? { getModelDisabledReason: props.getModelDisabledReason } | |
| 283 | : {})} | |
| 280 | 284 | onInstanceModelChange={onInstanceModelChange} |
| 281 | 285 | />, |
| 282 | 286 | { container: host }, |
| ... | ... | @@ -461,7 +465,7 @@ describe("ProviderModelPicker", () => { |
| 461 | 465 | } |
| 462 | 466 | }); |
| 463 | 467 | |
| 464 | it("shows locked provider header and only its models in locked mode", async () => { | |
| 468 | it("keeps the full provider rail in locked mode and only lists compatible models", async () => { | |
| 465 | 469 | localStorage.setItem( |
| 466 | 470 | "t3code:client-settings:v1", |
| 467 | 471 | JSON.stringify({ |
| ... | ... | @@ -484,8 +488,21 @@ describe("ProviderModelPicker", () => { |
| 484 | 488 | |
| 485 | 489 | await vi.waitFor(() => { |
| 486 | 490 | const text = document.body.textContent ?? ""; |
| 487 | // Should show locked provider label | |
| 488 | 491 | expect(text).toContain("Claude"); |
| 492 | // Locked-compatible instances render first, then disabled ones. | |
| 493 | expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ | |
| 494 | "favorites", | |
| 495 | "claudeAgent", | |
| 496 | "codex", | |
| 497 | ]); | |
| 498 | expect( | |
| 499 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="codex"]') | |
| 500 | ?.disabled, | |
| 501 | ).toBe(true); | |
| 502 | expect( | |
| 503 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="claudeAgent"]') | |
| 504 | ?.disabled, | |
| 505 | ).toBe(false); | |
| 489 | 506 | expect(getVisibleModelNames()).toEqual([ |
| 490 | 507 | "Claude Sonnet 4.6", |
| 491 | 508 | "Claude Opus 4.6", |
| ... | ... | @@ -559,7 +576,21 @@ describe("ProviderModelPicker", () => { |
| 559 | 576 | await page.getByRole("button").click(); |
| 560 | 577 | |
| 561 | 578 | await vi.waitFor(() => { |
| 562 | expect(getSidebarProviderOrder()).toEqual(["codex", "codex_personal"]); | |
| 579 | expect(getSidebarProviderOrder().slice(0, 5)).toEqual([ | |
| 580 | "favorites", | |
| 581 | "codex", | |
| 582 | "codex_personal", | |
| 583 | "codex_isolated", | |
| 584 | "claudeAgent", | |
| 585 | ]); | |
| 586 | expect( | |
| 587 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="codex_isolated"]') | |
| 588 | ?.disabled, | |
| 589 | ).toBe(true); | |
| 590 | expect( | |
| 591 | document.querySelector<HTMLButtonElement>('[data-model-picker-provider="claudeAgent"]') | |
| 592 | ?.disabled, | |
| 593 | ).toBe(true); | |
| 563 | 594 | expect(getModelPickerListText()).not.toContain("Codex Isolated"); |
| 564 | 595 | expect( |
| 565 | 596 | document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]') |
| ... | ... | @@ -625,7 +656,7 @@ describe("ProviderModelPicker", () => { |
| 625 | 656 | } |
| 626 | 657 | }); |
| 627 | 658 | |
| 628 | it("uses the trigger label for locked opencode rows", async () => { | |
| 659 | it("shows the plain model name in the trigger and provider details on locked opencode rows", async () => { | |
| 629 | 660 | const providers: ReadonlyArray<ServerProvider> = [ |
| 630 | 661 | buildOpenCodeProvider([ |
| 631 | 662 | { |
| ... | ... | @@ -658,14 +689,15 @@ describe("ProviderModelPicker", () => { |
| 658 | 689 | const trigger = document.querySelector<HTMLElement>( |
| 659 | 690 | '[data-chat-provider-model-picker="true"]', |
| 660 | 691 | ); |
| 661 | expect(trigger?.textContent).toContain("GitHub Copilot"); | |
| 662 | 692 | expect(trigger?.textContent).toContain("Opus 4.5"); |
| 693 | expect(trigger?.textContent).not.toContain("GitHub Copilot"); | |
| 663 | 694 | }); |
| 664 | 695 | |
| 665 | 696 | await page.getByRole("button").click(); |
| 666 | 697 | |
| 667 | 698 | await vi.waitFor(() => { |
| 668 | expect(getVisibleModelNames()).toEqual(["GitHub Copilot · Opus 4.5"]); | |
| 699 | expect(getVisibleModelNames()).toEqual(["Claude Opus 4.5"]); | |
| 700 | expect(getModelPickerListText()).toContain("OpenCode · GitHub Copilot"); | |
| 669 | 701 | }); |
| 670 | 702 | } finally { |
| 671 | 703 | await mounted.cleanup(); |
| ... | ... | @@ -961,27 +993,30 @@ describe("ProviderModelPicker", () => { |
| 961 | 993 | expect(text).toContain("Claude Opus 4.6"); |
| 962 | 994 | }); |
| 963 | 995 | |
| 964 | const getFirstStarButton = () => { | |
| 965 | const starButton = document.querySelector<HTMLButtonElement>( | |
| 996 | const getFavoriteButton = () => { | |
| 997 | const modelRow = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find( | |
| 998 | (row) => row.textContent?.includes("Claude Opus 4.6"), | |
| 999 | ); | |
| 1000 | const starButton = modelRow?.querySelector<HTMLButtonElement>( | |
| 966 | 1001 | 'button[aria-label*="favorites"]', |
| 967 | 1002 | ); |
| 968 | 1003 | expect(starButton).not.toBeNull(); |
| 969 | 1004 | return starButton!; |
| 970 | 1005 | }; |
| 971 | 1006 | |
| 972 | const firstStar = getFirstStarButton(); | |
| 973 | const initialAriaLabel = firstStar.getAttribute("aria-label"); | |
| 1007 | const favoriteButton = getFavoriteButton(); | |
| 1008 | const initialAriaLabel = favoriteButton.getAttribute("aria-label"); | |
| 974 | 1009 | expect( |
| 975 | 1010 | initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", |
| 976 | 1011 | ).toBe(true); |
| 977 | 1012 | |
| 978 | await page.getByRole("button", { name: initialAriaLabel! }).first().click(); | |
| 1013 | await userEvent.click(favoriteButton); | |
| 979 | 1014 | |
| 980 | 1015 | const expectedAriaLabel = |
| 981 | 1016 | initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; |
| 982 | 1017 | |
| 983 | 1018 | await vi.waitFor(() => { |
| 984 | expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel); | |
| 1019 | expect(getFavoriteButton().getAttribute("aria-label")).toBe(expectedAriaLabel); | |
| 985 | 1020 | }); |
| 986 | 1021 | } finally { |
| 987 | 1022 | await mounted.cleanup(); |
| ... | ... | @@ -1050,6 +1085,38 @@ describe("ProviderModelPicker", () => { |
| 1050 | 1085 | } |
| 1051 | 1086 | }); |
| 1052 | 1087 | |
| 1088 | it("filters favorites to compatible models in locked mode", async () => { | |
| 1089 | localStorage.setItem( | |
| 1090 | "t3code:client-settings:v1", | |
| 1091 | JSON.stringify({ | |
| 1092 | ...DEFAULT_CLIENT_SETTINGS, | |
| 1093 | favorites: [ | |
| 1094 | { provider: "codex", model: "gpt-5.3-codex" }, | |
| 1095 | { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | |
| 1096 | ], | |
| 1097 | }), | |
| 1098 | ); | |
| 1099 | ||
| 1100 | const mounted = await mountPicker({ | |
| 1101 | activeInstanceId: CLAUDE_INSTANCE_ID, | |
| 1102 | model: "claude-opus-4-6", | |
| 1103 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | |
| 1104 | }); | |
| 1105 | ||
| 1106 | try { | |
| 1107 | await page.getByRole("button").click(); | |
| 1108 | await page.getByRole("button", { name: "Favorites", exact: true }).click(); | |
| 1109 | ||
| 1110 | await vi.waitFor(() => { | |
| 1111 | expect(getVisibleModelNames()).toEqual(["Claude Sonnet 4.6"]); | |
| 1112 | expect(getModelPickerListText()).not.toContain("GPT-5.3 Codex"); | |
| 1113 | }); | |
| 1114 | } finally { | |
| 1115 | await mounted.cleanup(); | |
| 1116 | localStorage.removeItem("t3code:client-settings:v1"); | |
| 1117 | } | |
| 1118 | }); | |
| 1119 | ||
| 1053 | 1120 | it("dispatches callback with correct provider and model when selected", async () => { |
| 1054 | 1121 | const mounted = await mountPicker({ |
| 1055 | 1122 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| ... | ... | @@ -1079,6 +1146,29 @@ describe("ProviderModelPicker", () => { |
| 1079 | 1146 | } |
| 1080 | 1147 | }); |
| 1081 | 1148 | |
| 1149 | it("does not select models blocked by the provider", async () => { | |
| 1150 | const disabledReason = | |
| 1151 | "This provider does not allow switching models after a conversation has started. Start a new thread to use this model."; | |
| 1152 | const mounted = await mountPicker({ | |
| 1153 | activeInstanceId: CLAUDE_INSTANCE_ID, | |
| 1154 | model: "claude-opus-4-6", | |
| 1155 | lockedProvider: ProviderDriverKind.make("claudeAgent"), | |
| 1156 | getModelDisabledReason: (instanceId, model) => | |
| 1157 | instanceId === CLAUDE_INSTANCE_ID && model !== "claude-opus-4-6" ? disabledReason : null, | |
| 1158 | }); | |
| 1159 | ||
| 1160 | try { | |
| 1161 | await page.getByRole("button").click(); | |
| 1162 | ||
| 1163 | const blockedModel = page.getByText("Claude Sonnet 4.6").first(); | |
| 1164 | await blockedModel.click(); | |
| 1165 | expect(mounted.onProviderModelChange).not.toHaveBeenCalled(); | |
| 1166 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | |
| 1167 | } finally { | |
| 1168 | await mounted.cleanup(); | |
| 1169 | } | |
| 1170 | }); | |
| 1171 | ||
| 1082 | 1172 | it("only shows codex spark when the server reports it", async () => { |
| 1083 | 1173 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ |
| 1084 | 1174 | buildCodexProvider([ |
apps/web/src/components/chat/ProviderModelPicker.tsx+66-34| ... | ... | @@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 41 | 41 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 42 | 42 | triggerClassName?: string; |
| 43 | 43 | onOpenChange?: (open: boolean) => void; |
| 44 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | |
| 44 | 45 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 45 | 46 | }) { |
| 46 | 47 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| ... | ... | @@ -65,7 +66,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 65 | 66 | selectedInstanceOptions.find((option) => option.slug === props.model) ?? |
| 66 | 67 | selectedInstanceOptions[0]; |
| 67 | 68 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; |
| 68 | const triggerSubtitle = selectedModel?.subProvider; | |
| 69 | 69 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; |
| 70 | 70 | const duplicateDriverCount = props.instanceEntries.filter( |
| 71 | 71 | (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, |
| ... | ... | @@ -86,6 +86,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 86 | 86 | }; |
| 87 | 87 | }, [isMenuOpen]); |
| 88 | 88 | |
| 89 | useEffect(() => { | |
| 90 | if (!isMenuOpen) { | |
| 91 | return; | |
| 92 | } | |
| 93 | ||
| 94 | const { documentElement, body } = document; | |
| 95 | const previousDocumentOverscrollBehavior = documentElement.style.overscrollBehavior; | |
| 96 | const previousBodyOverflow = body.style.overflow; | |
| 97 | const previousBodyPaddingRight = body.style.paddingRight; | |
| 98 | const scrollbarWidth = window.innerWidth - documentElement.clientWidth; | |
| 99 | ||
| 100 | documentElement.style.overscrollBehavior = "contain"; | |
| 101 | body.style.overflow = "hidden"; | |
| 102 | if (scrollbarWidth > 0) { | |
| 103 | body.style.paddingRight = `${scrollbarWidth}px`; | |
| 104 | } | |
| 105 | ||
| 106 | const shouldAllowOverlayScroll = (target: EventTarget | null) => { | |
| 107 | return target instanceof Element && target.closest("[data-model-picker-content]"); | |
| 108 | }; | |
| 109 | const preventBackgroundWheel = (event: WheelEvent) => { | |
| 110 | if (shouldAllowOverlayScroll(event.target)) { | |
| 111 | return; | |
| 112 | } | |
| 113 | event.preventDefault(); | |
| 114 | }; | |
| 115 | const preventBackgroundTouchMove = (event: TouchEvent) => { | |
| 116 | if (shouldAllowOverlayScroll(event.target)) { | |
| 117 | return; | |
| 118 | } | |
| 119 | event.preventDefault(); | |
| 120 | }; | |
| 121 | ||
| 122 | document.addEventListener("wheel", preventBackgroundWheel, { capture: true, passive: false }); | |
| 123 | document.addEventListener("touchmove", preventBackgroundTouchMove, { | |
| 124 | capture: true, | |
| 125 | passive: false, | |
| 126 | }); | |
| 127 | ||
| 128 | return () => { | |
| 129 | document.removeEventListener("wheel", preventBackgroundWheel, { capture: true }); | |
| 130 | document.removeEventListener("touchmove", preventBackgroundTouchMove, { capture: true }); | |
| 131 | documentElement.style.overscrollBehavior = previousDocumentOverscrollBehavior; | |
| 132 | body.style.overflow = previousBodyOverflow; | |
| 133 | body.style.paddingRight = previousBodyPaddingRight; | |
| 134 | }; | |
| 135 | }, [isMenuOpen]); | |
| 136 | ||
| 89 | 137 | const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { |
| 90 | 138 | if (props.disabled) return; |
| 91 | 139 | props.onInstanceModelChange(instanceId, model); |
| ... | ... | @@ -110,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 110 | 158 | variant={props.triggerVariant ?? "ghost"} |
| 111 | 159 | data-chat-provider-model-picker="true" |
| 112 | 160 | className={cn( |
| 113 | "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", | |
| 161 | "min-w-0 justify-between whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80", | |
| 114 | 162 | props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", |
| 115 | 163 | props.triggerClassName, |
| 116 | 164 | )} |
| ... | ... | @@ -118,12 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 118 | 166 | /> |
| 119 | 167 | } |
| 120 | 168 | > |
| 121 | <span | |
| 122 | className={cn( | |
| 123 | "flex min-w-0 w-full box-border items-center gap-2 overflow-hidden", | |
| 124 | props.compact ? "max-w-36 sm:pl-1" : undefined, | |
| 125 | )} | |
| 126 | > | |
| 169 | <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"> | |
| 127 | 170 | {activeEntry ? ( |
| 128 | 171 | <ProviderInstanceIcon |
| 129 | 172 | driverKind={activeEntry.driverKind} |
| ... | ... | @@ -132,42 +175,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 132 | 175 | showBadge={showInstanceBadge} |
| 133 | 176 | className={showInstanceBadge ? "size-5" : "size-4"} |
| 134 | 177 | iconClassName={cn("size-4", props.activeProviderIconClassName)} |
| 135 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" | |
| 178 | indicatorBackground="var(--input)" | |
| 179 | badgeClassName={cn( | |
| 180 | "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3", | |
| 181 | "px-0.5 text-[7px]", | |
| 182 | )} | |
| 136 | 183 | /> |
| 137 | 184 | ) : null} |
| 138 | 185 | <Tooltip> |
| 139 | <TooltipTrigger | |
| 140 | render={ | |
| 141 | <span | |
| 142 | className={cn( | |
| 143 | "min-w-0 flex-1 overflow-hidden", | |
| 144 | triggerSubtitle | |
| 145 | ? "grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1" | |
| 146 | : "truncate", | |
| 147 | )} | |
| 148 | /> | |
| 149 | } | |
| 150 | > | |
| 151 | {triggerSubtitle ? ( | |
| 152 | <> | |
| 153 | <span className="min-w-0 truncate">{triggerSubtitle}</span> | |
| 154 | <span aria-hidden="true" className="shrink-0 opacity-60"> | |
| 155 | · | |
| 156 | </span> | |
| 157 | <span className="min-w-0 truncate">{triggerTitle}</span> | |
| 158 | </> | |
| 159 | ) : ( | |
| 160 | triggerTitle | |
| 161 | )} | |
| 186 | <TooltipTrigger render={<span className="min-w-0 flex-1 overflow-hidden truncate" />}> | |
| 187 | {triggerTitle} | |
| 162 | 188 | </TooltipTrigger> |
| 163 | 189 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> |
| 164 | 190 | </Tooltip> |
| 165 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> | |
| 191 | </span> | |
| 192 | <span aria-hidden="true" className="flex items-center"> | |
| 193 | <ChevronDownIcon aria-hidden="true" className="!ms-0 !-me-1 size-3 shrink-0 opacity-60" /> | |
| 166 | 194 | </span> |
| 167 | 195 | </PopoverTrigger> |
| 168 | 196 | <PopoverPopup |
| 169 | 197 | align="start" |
| 170 | className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0] *:data-[slot=popover-viewport]:p-0" | |
| 198 | className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0]" | |
| 199 | viewportClassName="!overflow-hidden p-0" | |
| 171 | 200 | > |
| 172 | 201 | <ModelPickerContent |
| 173 | 202 | activeInstanceId={activeInstanceId} |
| ... | ... | @@ -179,6 +208,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 179 | 208 | modelOptionsByInstance={props.modelOptionsByInstance} |
| 180 | 209 | terminalOpen={props.terminalOpen ?? false} |
| 181 | 210 | onRequestClose={() => setIsMenuOpen(false)} |
| 211 | {...(props.getModelDisabledReason | |
| 212 | ? { getModelDisabledReason: props.getModelDisabledReason } | |
| 213 | : {})} | |
| 182 | 214 | onInstanceModelChange={handleInstanceModelChange} |
| 183 | 215 | /> |
| 184 | 216 | </PopoverPopup> |
apps/web/src/components/chat/providerIconUtils.ts+17-6| ... | ... | @@ -28,14 +28,26 @@ export type ModelEsque = { |
| 28 | 28 | subProvider?: string | undefined; |
| 29 | 29 | }; |
| 30 | 30 | |
| 31 | function escapeRegExp(value: string): string { | |
| 32 | return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 33 | } | |
| 34 | ||
| 35 | function stripLeadingQualifier(value: string, qualifier: string | null | undefined): string { | |
| 36 | const trimmedQualifier = qualifier?.trim(); | |
| 37 | if (!trimmedQualifier) { | |
| 38 | return value; | |
| 39 | } | |
| 40 | ||
| 41 | const pattern = new RegExp(`^${escapeRegExp(trimmedQualifier)}(?:\\s*[.:/-]\\s*|\\s+)`, "iu"); | |
| 42 | return value.replace(pattern, "").trim() || value; | |
| 43 | } | |
| 44 | ||
| 31 | 45 | export function getDisplayModelName( |
| 32 | 46 | model: ModelEsque, |
| 33 | 47 | options?: { preferShortName?: boolean }, |
| 34 | 48 | ): string { |
| 35 | if (options?.preferShortName && model.shortName) { | |
| 36 | return model.shortName; | |
| 37 | } | |
| 38 | return model.name; | |
| 49 | const name = options?.preferShortName && model.shortName ? model.shortName : model.name; | |
| 50 | return stripLeadingQualifier(name, model.subProvider); | |
| 39 | 51 | } |
| 40 | 52 | |
| 41 | 53 | export function getTriggerDisplayModelName(model: ModelEsque): string { |
| ... | ... | @@ -43,6 +55,5 @@ export function getTriggerDisplayModelName(model: ModelEsque): string { |
| 43 | 55 | } |
| 44 | 56 | |
| 45 | 57 | export function getTriggerDisplayModelLabel(model: ModelEsque): string { |
| 46 | const title = getTriggerDisplayModelName(model); | |
| 47 | return model.subProvider ? `${model.subProvider} · ${title}` : title; | |
| 58 | return getTriggerDisplayModelName(model); | |
| 48 | 59 | } |
apps/web/src/components/ui/combobox.tsx+3| ... | ... | @@ -60,6 +60,7 @@ function ComboboxInput({ |
| 60 | 60 | showClear = false, |
| 61 | 61 | startAddon, |
| 62 | 62 | size, |
| 63 | unstyled = false, | |
| 63 | 64 | ...props |
| 64 | 65 | }: Omit<ComboboxPrimitive.Input.Props, "size"> & { |
| 65 | 66 | inputClassName?: string; |
| ... | ... | @@ -67,6 +68,7 @@ function ComboboxInput({ |
| 67 | 68 | showClear?: boolean; |
| 68 | 69 | startAddon?: React.ReactNode; |
| 69 | 70 | size?: "sm" | "default" | "lg" | number; |
| 71 | unstyled?: boolean; | |
| 70 | 72 | ref?: React.Ref<HTMLInputElement>; |
| 71 | 73 | }) { |
| 72 | 74 | const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number; |
| ... | ... | @@ -97,6 +99,7 @@ function ComboboxInput({ |
| 97 | 99 | className={cn("has-disabled:opacity-100", inputClassName)} |
| 98 | 100 | nativeInput |
| 99 | 101 | size={sizeValue} |
| 102 | unstyled={unstyled} | |
| 100 | 103 | /> |
| 101 | 104 | } |
| 102 | 105 | {...props} |
apps/web/src/components/ui/popover.tsx+3| ... | ... | @@ -19,6 +19,7 @@ function PopoverTrigger({ className, children, ...props }: PopoverPrimitive.Trig |
| 19 | 19 | function PopoverPopup({ |
| 20 | 20 | children, |
| 21 | 21 | className, |
| 22 | viewportClassName, | |
| 22 | 23 | side = "bottom", |
| 23 | 24 | align = "center", |
| 24 | 25 | sideOffset = 4, |
| ... | ... | @@ -27,6 +28,7 @@ function PopoverPopup({ |
| 27 | 28 | anchor, |
| 28 | 29 | ...props |
| 29 | 30 | }: PopoverPrimitive.Popup.Props & { |
| 31 | viewportClassName?: string; | |
| 30 | 32 | side?: PopoverPrimitive.Positioner.Props["side"]; |
| 31 | 33 | align?: PopoverPrimitive.Positioner.Props["align"]; |
| 32 | 34 | sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]; |
| ... | ... | @@ -61,6 +63,7 @@ function PopoverPopup({ |
| 61 | 63 | tooltipStyle |
| 62 | 64 | ? "py-1 [--viewport-inline-padding:--spacing(2)]" |
| 63 | 65 | : "not-data-transitioning:overflow-y-auto", |
| 66 | viewportClassName, | |
| 64 | 67 | )} |
| 65 | 68 | data-slot="popover-viewport" |
| 66 | 69 | > |