| 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,6 +3601,23 @@ export default function ChatView(props: ChatViewProps) { |
| 3601 | composerRef, | 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 | const onProviderModelSelect = useCallback( | 3621 | const onProviderModelSelect = useCallback( |
| 3605 | (instanceId: ProviderInstanceId, model: string) => { | 3622 | (instanceId: ProviderInstanceId, model: string) => { |
| 3606 | if (!activeThread) return; | 3623 | if (!activeThread) return; |
| ... | @@ -3919,6 +3936,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3919,6 +3936,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3919 | onChangeActivePendingUserInputCustomAnswer | 3936 | onChangeActivePendingUserInputCustomAnswer |
| 3920 | } | 3937 | } |
| 3921 | onProviderModelSelect={onProviderModelSelect} | 3938 | onProviderModelSelect={onProviderModelSelect} |
| 3939 | getModelDisabledReason={getModelDisabledReason} | ||
| 3922 | toggleInteractionMode={toggleInteractionMode} | 3940 | toggleInteractionMode={toggleInteractionMode} |
| 3923 | handleRuntimeModeChange={handleRuntimeModeChange} | 3941 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 3924 | handleInteractionModeChange={handleInteractionModeChange} | 3942 | handleInteractionModeChange={handleInteractionModeChange} |
apps/web/src/components/chat/ChatComposer.tsx+3| ... | @@ -489,6 +489,7 @@ export interface ChatComposerProps { | ... | @@ -489,6 +489,7 @@ export interface ChatComposerProps { |
| 489 | ) => void; | 489 | ) => void; |
| 490 | 490 | ||
| 491 | onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; | 491 | onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; |
| 492 | getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; | ||
| 492 | toggleInteractionMode: () => void; | 493 | toggleInteractionMode: () => void; |
| 493 | handleRuntimeModeChange: (mode: RuntimeMode) => void; | 494 | handleRuntimeModeChange: (mode: RuntimeMode) => void; |
| 494 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; | 495 | handleInteractionModeChange: (mode: ProviderInteractionMode) => void; |
| ... | @@ -563,6 +564,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -563,6 +564,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 563 | onPreviousActivePendingUserInputQuestion, | 564 | onPreviousActivePendingUserInputQuestion, |
| 564 | onChangeActivePendingUserInputCustomAnswer, | 565 | onChangeActivePendingUserInputCustomAnswer, |
| 565 | onProviderModelSelect, | 566 | onProviderModelSelect, |
| 567 | getModelDisabledReason, | ||
| 566 | toggleInteractionMode, | 568 | toggleInteractionMode, |
| 567 | handleRuntimeModeChange, | 569 | handleRuntimeModeChange, |
| 568 | handleInteractionModeChange, | 570 | handleInteractionModeChange, |
| ... | @@ -2347,6 +2349,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2347,6 +2349,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2347 | onOpenChange={(open) => { | 2349 | onOpenChange={(open) => { |
| 2348 | setIsComposerModelPickerOpen(open); | 2350 | setIsComposerModelPickerOpen(open); |
| 2349 | }} | 2351 | }} |
| 2352 | getModelDisabledReason={getModelDisabledReason} | ||
| 2350 | onInstanceModelChange={onProviderModelSelect} | 2353 | onInstanceModelChange={onProviderModelSelect} |
| 2351 | /> | 2354 | /> |
| 2352 | 2355 |
apps/web/src/components/chat/ModelListRow.tsx+79-62| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { StarIcon } from "lucide-react"; | 3 | import { CheckIcon, StarIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | getDisplayModelName, | 5 | getDisplayModelName, |
| 6 | getTriggerDisplayModelLabel, | 6 | getTriggerDisplayModelLabel, |
| ... | @@ -27,11 +27,13 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -27,11 +27,13 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 27 | providerDisplayName: string; | 27 | providerDisplayName: string; |
| 28 | providerAccentColor?: string | undefined; | 28 | providerAccentColor?: string | undefined; |
| 29 | isFavorite: boolean; | 29 | isFavorite: boolean; |
| 30 | isSelected: boolean; | ||
| 30 | showProvider: boolean; | 31 | showProvider: boolean; |
| 31 | preferShortName?: boolean; | 32 | preferShortName?: boolean; |
| 32 | useTriggerLabel?: boolean; | 33 | useTriggerLabel?: boolean; |
| 33 | showNewBadge?: boolean; | 34 | showNewBadge?: boolean; |
| 34 | jumpLabel?: string | null; | 35 | jumpLabel?: string | null; |
| 36 | disabledReason?: string | null; | ||
| 35 | onToggleFavorite: () => void; | 37 | onToggleFavorite: () => void; |
| 36 | }) { | 38 | }) { |
| 37 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; | 39 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| ... | @@ -39,85 +41,100 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -39,85 +41,100 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 39 | ? `${props.providerDisplayName} · ${props.model.subProvider}` | 41 | ? `${props.providerDisplayName} · ${props.model.subProvider}` |
| 40 | : props.providerDisplayName; | 42 | : props.providerDisplayName; |
| 41 | 43 | ||
| 42 | return ( | 44 | const row = ( |
| 43 | <ComboboxItem | 45 | <ComboboxItem |
| 44 | hideIndicator | 46 | hideIndicator |
| 45 | index={props.index} | 47 | index={props.index} |
| 46 | value={`${props.instanceId}:${props.model.slug}`} | 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 | className={cn( | 51 | className={cn( |
| 49 | "w-full cursor-pointer rounded px-3 py-2 transition-colors group", | 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]", |
| 50 | "data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground", | 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 | <div className="min-w-0 flex-1 text-left"> | 58 | <div className="min-w-0 flex-1 text-left"> |
| 80 | <div className="flex items-center justify-between gap-2 min-w-0"> | 59 | <div className="flex min-w-0 items-center gap-2"> |
| 81 | <div className="text-xs font-medium leading-snug flex items-center gap-2 min-w-0"> | 60 | <div className="min-w-0 truncate text-xs font-medium leading-snug"> |
| 82 | <span className="truncate"> | 61 | {props.useTriggerLabel |
| 83 | {props.useTriggerLabel | 62 | ? getTriggerDisplayModelLabel(props.model) |
| 84 | ? getTriggerDisplayModelLabel(props.model) | 63 | : getDisplayModelName( |
| 85 | : getDisplayModelName( | 64 | props.model, |
| 86 | props.model, | 65 | props.preferShortName ? { preferShortName: true } : undefined, |
| 87 | props.preferShortName ? { preferShortName: true } : undefined, | 66 | )} |
| 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} | ||
| 98 | </div> | 67 | </div> |
| 99 | {props.jumpLabel ? ( | 68 | {props.isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null} |
| 100 | <Kbd className="h-4 min-w-0 shrink-0 rounded-sm px-1.5 text-[10px]"> | 69 | {props.showNewBadge ? ( |
| 101 | {props.jumpLabel} | 70 | <span |
| 102 | </Kbd> | 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 | ) : null} | 76 | ) : null} |
| 104 | </div> | 77 | </div> |
| 105 | {props.showProvider && ( | 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 | {ProviderIcon ? <ProviderIcon className="size-3 shrink-0" /> : null} | 80 | {ProviderIcon ? <ProviderIcon className="size-3 shrink-0" /> : null} |
| 108 | {props.providerAccentColor ? ( | 81 | <span className="truncate text-xs font-normal leading-snug text-muted-foreground/70"> |
| 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"> | ||
| 116 | {providerLabel} | 82 | {providerLabel} |
| 117 | </span> | 83 | </span> |
| 118 | </div> | 84 | </div> |
| 119 | )} | 85 | )} |
| 120 | </div> | 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 | </ComboboxItem> | 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,14 +4,15 @@ import { |
| 4 | type ResolvedKeybindingsConfig, | 4 | type ResolvedKeybindingsConfig, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { resolveSelectableModel } from "@t3tools/shared/model"; | 6 | import { resolveSelectableModel } from "@t3tools/shared/model"; |
| 7 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | ||
| 7 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; | 8 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; |
| 8 | import { SearchIcon } from "lucide-react"; | 9 | import { SearchIcon } from "lucide-react"; |
| 9 | import { ModelListRow } from "./ModelListRow"; | 10 | import { ModelListRow } from "./ModelListRow"; |
| 10 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; | 11 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; |
| 11 | import { isModelPickerNewModel } from "./modelPickerModelHighlights"; | 12 | import { isModelPickerNewModel } from "./modelPickerModelHighlights"; |
| 12 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; | 13 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; |
| 13 | import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; | 14 | import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox"; |
| 14 | import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | 15 | import { ModelEsque } from "./providerIconUtils"; |
| 15 | import { | 16 | import { |
| 16 | modelPickerJumpCommandForIndex, | 17 | modelPickerJumpCommandForIndex, |
| 17 | modelPickerJumpIndexFromCommand, | 18 | modelPickerJumpIndexFromCommand, |
| ... | @@ -81,17 +82,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -81,17 +82,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 81 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; | 82 | modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>; |
| 82 | terminalOpen: boolean; | 83 | terminalOpen: boolean; |
| 83 | onRequestClose?: () => void; | 84 | onRequestClose?: () => void; |
| 85 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | ||
| 84 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; | 86 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 85 | }) { | 87 | }) { |
| 86 | const { | 88 | const { |
| 87 | keybindings: providedKeybindings, | 89 | keybindings: providedKeybindings, |
| 88 | modelOptionsByInstance, | 90 | modelOptionsByInstance, |
| 89 | instanceEntries, | 91 | instanceEntries, |
| 92 | getModelDisabledReason, | ||
| 90 | onInstanceModelChange, | 93 | onInstanceModelChange, |
| 91 | } = props; | 94 | } = props; |
| 92 | const [searchQuery, setSearchQuery] = useState(""); | 95 | const [searchQuery, setSearchQuery] = useState(""); |
| 96 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); | ||
| 97 | const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); | ||
| 93 | const searchInputRef = useRef<HTMLInputElement>(null); | 98 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 94 | const listRegionRef = useRef<HTMLDivElement>(null); | 99 | const modelListRef = useRef<LegendListRef | null>(null); |
| 95 | const highlightedModelKeyRef = useRef<string | null>(null); | 100 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 96 | const favorites = useSettings((s) => s.favorites ?? []); | 101 | const favorites = useSettings((s) => s.favorites ?? []); |
| 97 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">( | 102 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">( |
| ... | @@ -213,16 +218,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -213,16 +218,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 213 | 218 | ||
| 214 | const isLocked = props.lockedProvider !== null; | 219 | const isLocked = props.lockedProvider !== null; |
| 215 | const isSearching = searchQuery.trim().length > 0; | 220 | const isSearching = searchQuery.trim().length > 0; |
| 216 | const lockedInstanceEntries = useMemo( | 221 | const lockedDisabledInstanceIds = useMemo(() => { |
| 217 | () => | 222 | if (!isLocked) { |
| 218 | props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], | 223 | return undefined; |
| 219 | [instanceEntries, matchesLockedProvider, props.lockedProvider], | 224 | } |
| 220 | ); | 225 | const disabled = new Set<ProviderInstanceId>(); |
| 221 | const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; | 226 | for (const entry of instanceEntries) { |
| 222 | const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); | 227 | if (!matchesLockedProvider(entry)) { |
| 223 | const sidebarInstanceEntries = showLockedInstanceSidebar | 228 | disabled.add(entry.instanceId); |
| 224 | ? lockedInstanceEntries | 229 | } |
| 225 | : instanceEntries; | 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 | const instanceOrder = useMemo( | 249 | const instanceOrder = useMemo( |
| 227 | () => instanceEntries.map((entry) => entry.instanceId), | 250 | () => instanceEntries.map((entry) => entry.instanceId), |
| 228 | [instanceEntries], | 251 | [instanceEntries], |
| ... | @@ -308,7 +331,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -308,7 +331,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 308 | 331 | ||
| 309 | if (props.lockedProvider !== null) { | 332 | if (props.lockedProvider !== null) { |
| 310 | result = result.filter((m) => matchesLockedProvider(m)); | 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 | result = result.filter((m) => m.instanceId === selectedInstanceId); | 337 | result = result.filter((m) => m.instanceId === selectedInstanceId); |
| 313 | } | 338 | } |
| 314 | } else if (selectedInstanceId === "favorites") { | 339 | } else if (selectedInstanceId === "favorites") { |
| ... | @@ -329,12 +354,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -329,12 +354,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 329 | matchesLockedProvider, | 354 | matchesLockedProvider, |
| 330 | props.lockedProvider, | 355 | props.lockedProvider, |
| 331 | searchQuery, | 356 | searchQuery, |
| 332 | showLockedInstanceSidebar, | ||
| 333 | selectedInstanceId, | 357 | selectedInstanceId, |
| 334 | ]); | 358 | ]); |
| 335 | 359 | ||
| 336 | const handleModelSelect = useCallback( | 360 | const handleModelSelect = useCallback( |
| 337 | (modelSlug: string, instanceId: ProviderInstanceId) => { | 361 | (modelSlug: string, instanceId: ProviderInstanceId) => { |
| 362 | if (getModelDisabledReason?.(instanceId, modelSlug)) { | ||
| 363 | return; | ||
| 364 | } | ||
| 338 | const options = modelOptionsByInstance.get(instanceId); | 365 | const options = modelOptionsByInstance.get(instanceId); |
| 339 | if (!options) { | 366 | if (!options) { |
| 340 | return; | 367 | return; |
| ... | @@ -351,7 +378,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -351,7 +378,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 351 | onInstanceModelChange(instanceId, resolvedModel); | 378 | onInstanceModelChange(instanceId, resolvedModel); |
| 352 | } | 379 | } |
| 353 | }, | 380 | }, |
| 354 | [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange], | 381 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], |
| 355 | ); | 382 | ); |
| 356 | 383 | ||
| 357 | const toggleFavorite = useCallback( | 384 | const toggleFavorite = useCallback( |
| ... | @@ -368,39 +395,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -368,39 +395,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 368 | [favorites, updateSettings], | 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 | const modelJumpCommandByKey = useMemo(() => { | 398 | const modelJumpCommandByKey = useMemo(() => { |
| 391 | const mapping = new Map< | 399 | const mapping = new Map< |
| 392 | string, | 400 | string, |
| 393 | NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>> | 401 | NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>> |
| 394 | >(); | 402 | >(); |
| 395 | for (const [visibleModelIndex, model] of filteredModels.entries()) { | 403 | let selectableModelIndex = 0; |
| 396 | const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); | 404 | for (const model of filteredModels) { |
| 405 | if (getModelDisabledReason?.(model.instanceId, model.slug)) { | ||
| 406 | continue; | ||
| 407 | } | ||
| 408 | const jumpCommand = modelPickerJumpCommandForIndex(selectableModelIndex); | ||
| 397 | if (!jumpCommand) { | 409 | if (!jumpCommand) { |
| 398 | return mapping; | 410 | return mapping; |
| 399 | } | 411 | } |
| 400 | mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); | 412 | mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); |
| 413 | selectableModelIndex += 1; | ||
| 401 | } | 414 | } |
| 402 | return mapping; | 415 | return mapping; |
| 403 | }, [filteredModels]); | 416 | }, [filteredModels, getModelDisabledReason]); |
| 404 | const modelJumpModelKeys = useMemo( | 417 | const modelJumpModelKeys = useMemo( |
| 405 | () => [...modelJumpCommandByKey.keys()], | 418 | () => [...modelJumpCommandByKey.keys()], |
| 406 | [modelJumpCommandByKey], | 419 | [modelJumpCommandByKey], |
| ... | @@ -418,6 +431,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -418,6 +431,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 418 | new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), | 431 | new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), |
| 419 | [filteredModels], | 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 | const modelJumpShortcutContext = useMemo( | 443 | const modelJumpShortcutContext = useMemo( |
| 422 | () => | 444 | () => |
| 423 | ({ | 445 | ({ |
| ... | @@ -478,72 +500,40 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -478,72 +500,40 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 478 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | 500 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); |
| 479 | 501 | ||
| 480 | useLayoutEffect(() => { | 502 | useLayoutEffect(() => { |
| 481 | const listRegion = listRegionRef.current; | 503 | setShowTopScrollFade(false); |
| 482 | if (!listRegion) { | 504 | setShowBottomScrollFade(filteredModelKeys.length > 5); |
| 483 | return; | ||
| 484 | } | ||
| 485 | |||
| 486 | let cancelled = false; | ||
| 487 | let frame = 0; | ||
| 488 | let nestedFrame = 0; | 505 | let nestedFrame = 0; |
| 489 | let timeout = 0; | 506 | const frame = window.requestAnimationFrame(() => { |
| 490 | 507 | updateModelListScrollFades(); | |
| 491 | const measureScrollArea = () => { | 508 | nestedFrame = window.requestAnimationFrame(updateModelListScrollFades); |
| 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); | ||
| 512 | }); | 509 | }); |
| 513 | timeout = window.setTimeout(measureScrollArea, 0); | ||
| 514 | |||
| 515 | return () => { | 510 | return () => { |
| 516 | cancelled = true; | ||
| 517 | window.cancelAnimationFrame(frame); | 511 | window.cancelAnimationFrame(frame); |
| 518 | window.cancelAnimationFrame(nestedFrame); | 512 | window.cancelAnimationFrame(nestedFrame); |
| 519 | window.clearTimeout(timeout); | ||
| 520 | }; | 513 | }; |
| 521 | }, [filteredModelKeys]); | 514 | }, [filteredModelKeys, updateModelListScrollFades]); |
| 522 | 515 | ||
| 523 | return ( | 516 | return ( |
| 524 | <TooltipProvider delay={0}> | 517 | <TooltipProvider delay={0}> |
| 525 | <div | 518 | <div |
| 526 | className={cn( | 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%)]" |
| 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%)]", | 520 | data-model-picker-content="true" |
| 528 | isLocked && !showLockedInstanceSidebar ? "flex-col" : "flex-row", | ||
| 529 | )} | ||
| 530 | > | 521 | > |
| 531 | {/* Locked provider header (only shown in locked mode) */} | 522 | {/* Sidebar */} |
| 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) */} | ||
| 540 | {showSidebar && ( | 523 | {showSidebar && ( |
| 541 | <ModelPickerSidebar | 524 | <ModelPickerSidebar |
| 542 | selectedInstanceId={selectedInstanceId} | 525 | selectedInstanceId={selectedInstanceId} |
| 543 | onSelectInstance={handleSelectInstance} | 526 | onSelectInstance={handleSelectInstance} |
| 544 | instanceEntries={sidebarInstanceEntries} | 527 | instanceEntries={sidebarInstanceEntries} |
| 545 | showFavorites={!isLocked} | 528 | showFavorites |
| 546 | showComingSoon={!isLocked} | 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,9 +545,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 555 | filter={null} | 545 | filter={null} |
| 556 | autoHighlight | 546 | autoHighlight |
| 557 | open | 547 | open |
| 548 | virtualized | ||
| 558 | value={`${props.activeInstanceId}:${props.model}`} | 549 | value={`${props.activeInstanceId}:${props.model}`} |
| 559 | onItemHighlighted={(modelKey) => { | 550 | onItemHighlighted={(modelKey, eventDetails) => { |
| 560 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; | 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 | onValueChange={(modelKey) => { | 559 | onValueChange={(modelKey) => { |
| 563 | if (typeof modelKey !== "string") { | 560 | if (typeof modelKey !== "string") { |
| ... | @@ -569,79 +566,101 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -569,79 +566,101 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 569 | > | 566 | > |
| 570 | <div | 567 | <div |
| 571 | className={cn( | 568 | className={cn( |
| 572 | "flex min-h-0 flex-1 flex-col overflow-hidden", | 569 | "flex min-h-0 flex-1 flex-col overflow-hidden bg-muted/40", |
| 573 | isLocked && !showLockedInstanceSidebar ? "min-w-0" : showSidebar && "border-l", | 570 | showSidebar && "border-l", |
| 574 | )} | 571 | )} |
| 575 | > | 572 | > |
| 576 | {/* Search bar */} | 573 | {/* Search bar */} |
| 577 | <div className="border-b px-3 py-2"> | 574 | <div className="px-4 pt-2.5"> |
| 578 | <ComboboxInput | 575 | <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring"> |
| 579 | ref={searchInputRef} | 576 | <ComboboxInput |
| 580 | className="[&_input]:font-sans rounded-md" | 577 | ref={searchInputRef} |
| 581 | inputClassName="border-0 shadow-none ring-0 focus-visible:ring-0" | 578 | className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" |
| 582 | placeholder="Search models..." | 579 | inputClassName="rounded-none bg-transparent text-sm" |
| 583 | showTrigger={false} | 580 | placeholder="Search models..." |
| 584 | startAddon={<SearchIcon className="size-4 shrink-0 text-muted-foreground/50" />} | 581 | showTrigger={false} |
| 585 | value={searchQuery} | 582 | startAddon={ |
| 586 | onChange={(e) => setSearchQuery(e.target.value)} | 583 | <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> |
| 587 | onKeyDown={(e) => { | ||
| 588 | if (e.key === "Escape") { | ||
| 589 | e.preventDefault(); | ||
| 590 | e.stopPropagation(); | ||
| 591 | props.onRequestClose?.(); | ||
| 592 | return; | ||
| 593 | } | 584 | } |
| 594 | if (e.key === "Enter" && highlightedModelKeyRef.current) { | 585 | value={searchQuery} |
| 595 | ( | 586 | onChange={(e) => setSearchQuery(e.target.value)} |
| 596 | e as typeof e & { preventBaseUIHandler?: () => void } | 587 | onKeyDown={(e) => { |
| 597 | ).preventBaseUIHandler?.(); | 588 | if (e.key === "Escape") { |
| 598 | e.preventDefault(); | 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 | e.stopPropagation(); | 606 | e.stopPropagation(); |
| 600 | const { instanceId, slug } = splitInstanceModelKey( | 607 | }} |
| 601 | highlightedModelKeyRef.current, | 608 | onMouseDown={(e) => e.stopPropagation()} |
| 602 | ); | 609 | onTouchStart={(e) => e.stopPropagation()} |
| 603 | handleModelSelect(slug, instanceId); | 610 | size="sm" |
| 604 | return; | 611 | unstyled |
| 605 | } | 612 | /> |
| 606 | e.stopPropagation(); | 613 | </div> |
| 607 | }} | ||
| 608 | onMouseDown={(e) => e.stopPropagation()} | ||
| 609 | onTouchStart={(e) => e.stopPropagation()} | ||
| 610 | size="sm" | ||
| 611 | /> | ||
| 612 | </div> | 614 | </div> |
| 613 | 615 | ||
| 614 | {/* Model list */} | 616 | {/* Model list */} |
| 615 | <div | 617 | <div className="relative min-h-0 flex-1 overflow-hidden"> |
| 616 | ref={listRegionRef} | 618 | <ComboboxListVirtualized className="model-picker-list size-full min-w-0 p-0"> |
| 617 | className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40" | 619 | <LegendList<string> |
| 618 | > | 620 | ref={modelListRef} |
| 619 | <ComboboxList className="model-picker-list size-full divide-y px-2 py-1"> | 621 | data={filteredModelKeys} |
| 620 | {filteredModelKeys.map((modelKey, index) => { | 622 | extraData={favoritesSet} |
| 621 | const model = filteredModelByKey.get(modelKey); | 623 | keyExtractor={(modelKey) => modelKey} |
| 622 | if (!model) { | 624 | renderItem={({ item: modelKey, index }) => { |
| 623 | return null; | 625 | const model = filteredModelByKey.get(modelKey); |
| 624 | } | 626 | if (!model) { |
| 625 | return ( | 627 | return null; |
| 626 | <ModelListRow | 628 | } |
| 627 | key={modelKey} | 629 | const disabledReason = |
| 628 | index={index} | 630 | getModelDisabledReason?.(model.instanceId, model.slug) ?? null; |
| 629 | model={model} | 631 | return ( |
| 630 | instanceId={model.instanceId} | 632 | <ModelListRow |
| 631 | driverKind={model.driverKind} | 633 | key={modelKey} |
| 632 | providerDisplayName={model.instanceDisplayName} | 634 | index={index} |
| 633 | providerAccentColor={model.instanceAccentColor} | 635 | model={model} |
| 634 | isFavorite={favoritesSet.has(modelKey)} | 636 | instanceId={model.instanceId} |
| 635 | showProvider={!isLocked || showLockedInstanceSidebar} | 637 | driverKind={model.driverKind} |
| 636 | preferShortName={!isLocked} | 638 | providerDisplayName={model.instanceDisplayName} |
| 637 | useTriggerLabel={isLocked && !showLockedInstanceSidebar} | 639 | providerAccentColor={model.instanceAccentColor} |
| 638 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} | 640 | isFavorite={favoritesSet.has(modelKey)} |
| 639 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} | 641 | isSelected={modelKey === `${props.activeInstanceId}:${props.model}`} |
| 640 | onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} | 642 | showProvider |
| 641 | /> | 643 | preferShortName={!isLocked} |
| 642 | ); | 644 | useTriggerLabel={false} |
| 643 | })} | 645 | showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} |
| 644 | </ComboboxList> | 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 | </div> | 664 | </div> |
| 646 | <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug"> | 665 | <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug"> |
| 647 | No models found | 666 | No models found |
apps/web/src/components/chat/ModelPickerSidebar.tsx+227-162| ... | @@ -1,9 +1,8 @@ | ... | @@ -1,9 +1,8 @@ |
| 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; | 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo, useMemo } from "react"; | 2 | import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; | 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; |
| 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; | 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; |
| 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; | 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| 9 | import type { ProviderInstanceEntry } from "../../providerInstances"; | 8 | import type { ProviderInstanceEntry } from "../../providerInstances"; |
| ... | @@ -30,9 +29,8 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { | ... | @@ -30,9 +29,8 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { |
| 30 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; | 29 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; |
| 31 | } | 30 | } |
| 32 | 31 | ||
| 33 | const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; | ||
| 34 | const SELECTED_INDICATOR_CLASS = | 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 | const BADGE_BASE_CLASS = | 34 | const BADGE_BASE_CLASS = |
| 37 | "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 "; | 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 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; | 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,6 +38,7 @@ const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; |
| 40 | 38 | ||
| 41 | /** Opens toward the rail so the list stays readable (not over the model names). */ | 39 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 42 | const PICKER_TOOLTIP_SIDE = "left" as const; | 40 | const PICKER_TOOLTIP_SIDE = "left" as const; |
| 41 | const PICKER_TOOLTIP_SIDE_OFFSET = 8; | ||
| 43 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; | 42 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; |
| 44 | 43 | ||
| 45 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | 44 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| ... | @@ -56,6 +55,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -56,6 +55,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 56 | showFavorites?: boolean; | 55 | showFavorites?: boolean; |
| 57 | /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ | 56 | /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ |
| 58 | showComingSoon?: boolean; | 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 | * Instance id values that should render the "new" sparkle badge. Callers | 62 | * Instance id values that should render the "new" sparkle badge. Callers |
| 61 | * pass the subset of default built-in ids they want flagged (custom | 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,6 +70,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 68 | }; | 70 | }; |
| 69 | const showFavorites = props.showFavorites ?? true; | 71 | const showFavorites = props.showFavorites ?? true; |
| 70 | const showComingSoon = props.showComingSoon ?? true; | 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 | const duplicateDriverCounts = useMemo(() => { | 76 | const duplicateDriverCounts = useMemo(() => { |
| 72 | const counts = new Map<string, number>(); | 77 | const counts = new Map<string, number>(); |
| 73 | for (const entry of props.instanceEntries) { | 78 | for (const entry of props.instanceEntries) { |
| ... | @@ -76,188 +81,248 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -76,188 +81,248 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 76 | return counts; | 81 | return counts; |
| 77 | }, [props.instanceEntries]); | 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 | return ( | 107 | return ( |
| 80 | <ScrollArea | 108 | <div |
| 81 | hideScrollbars | 109 | className="w-12 shrink-0 overflow-hidden border-r bg-muted/30" |
| 82 | scrollFade | ||
| 83 | className="w-12 shrink-0 border-r bg-muted/30" | ||
| 84 | data-model-picker-sidebar="true" | 110 | data-model-picker-sidebar="true" |
| 85 | > | 111 | > |
| 86 | <div className="flex min-h-full flex-col gap-1 p-1"> | 112 | <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> |
| 87 | {/* Favorites section */} | 113 | <div |
| 88 | {showFavorites ? ( | 114 | ref={sidebarContentRef} |
| 89 | <div className="pb-1 mb-1 border-b"> | 115 | className="relative flex min-h-full flex-col gap-1 px-1 pb-1 pt-0.5" |
| 90 | <div className="relative w-full"> | 116 | > |
| 91 | {props.selectedInstanceId === "favorites" && ( | 117 | {selectedIndicatorTop !== null ? ( |
| 92 | <div className={SELECTED_INDICATOR_CLASS} /> | 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 | <Tooltip> | 258 | <Tooltip> |
| 95 | <TooltipTrigger | 259 | <TooltipTrigger |
| 96 | render={ | 260 | render={ |
| 97 | <button | 261 | <span className="relative block w-full"> |
| 98 | className={cn( | 262 | <button |
| 99 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | 263 | className={cn( |
| 100 | props.selectedInstanceId === "favorites" && SELECTED_BUTTON_CLASS, | 264 | "relative isolate flex w-full aspect-square items-center justify-center rounded-md opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", |
| 101 | )} | 265 | )} |
| 102 | onClick={() => handleSelect("favorites")} | 266 | disabled |
| 103 | type="button" | 267 | type="button" |
| 104 | data-model-picker-provider="favorites" | 268 | data-model-picker-provider="gemini-coming-soon" |
| 105 | aria-label="Favorites" | 269 | aria-label="Gemini — coming soon" |
| 106 | > | 270 | > |
| 107 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | 271 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> |
| 108 | </button> | 272 | <span className={SOON_BADGE_CLASS} aria-hidden> |
| 273 | <Clock3Icon className="size-2" /> | ||
| 274 | </span> | ||
| 275 | </button> | ||
| 276 | </span> | ||
| 109 | } | 277 | } |
| 110 | /> | 278 | /> |
| 111 | <TooltipPopup | 279 | <TooltipPopup |
| 112 | side={PICKER_TOOLTIP_SIDE} | 280 | side={PICKER_TOOLTIP_SIDE} |
| 281 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | ||
| 113 | align="center" | 282 | align="center" |
| 114 | className={PICKER_TOOLTIP_CLASS} | 283 | className={PICKER_TOOLTIP_CLASS} |
| 115 | > | 284 | > |
| 116 | Favorites | 285 | Gemini — Coming soon |
| 117 | </TooltipPopup> | 286 | </TooltipPopup> |
| 118 | </Tooltip> | 287 | </Tooltip> |
| 119 | </div> | 288 | {/* Github Copilot button (coming soon) */} |
| 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} />} | ||
| 182 | <Tooltip> | 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 | <TooltipPopup | 313 | <TooltipPopup |
| 185 | side={PICKER_TOOLTIP_SIDE} | 314 | side={PICKER_TOOLTIP_SIDE} |
| 315 | sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | ||
| 186 | align="center" | 316 | align="center" |
| 187 | className={PICKER_TOOLTIP_CLASS} | 317 | className={PICKER_TOOLTIP_CLASS} |
| 188 | > | 318 | > |
| 189 | {tooltip} | 319 | Github Copilot — Coming soon |
| 190 | </TooltipPopup> | 320 | </TooltipPopup> |
| 191 | </Tooltip> | 321 | </Tooltip> |
| 192 | </div> | 322 | </> |
| 193 | ); | 323 | ) : null} |
| 194 | })} | 324 | </div> |
| 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} | ||
| 260 | </div> | 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,20 +19,24 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 19 | displayName: string; | 19 | displayName: string; |
| 20 | accentColor?: string | undefined; | 20 | accentColor?: string | undefined; |
| 21 | showBadge?: boolean; | 21 | showBadge?: boolean; |
| 22 | badgeContent?: "initials" | "none"; | ||
| 22 | className?: string; | 23 | className?: string; |
| 23 | iconClassName?: string; | 24 | iconClassName?: string; |
| 24 | badgeClassName?: string; | 25 | badgeClassName?: string; |
| 25 | statusDotClassName?: string; | 26 | statusDotClassName?: string; |
| 27 | indicatorBackground?: string; | ||
| 26 | }) { | 28 | }) { |
| 27 | const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; | 29 | const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; |
| 30 | const indicatorBackground = props.indicatorBackground ?? "var(--card)"; | ||
| 28 | const accentStyle = props.accentColor | 31 | const accentStyle = props.accentColor |
| 29 | ? ({ "--provider-accent": props.accentColor } as CSSProperties) | 32 | ? ({ "--provider-accent": props.accentColor } as CSSProperties) |
| 30 | : undefined; | 33 | : undefined; |
| 34 | const badgeContent = props.badgeContent ?? "initials"; | ||
| 31 | 35 | ||
| 32 | return ( | 36 | return ( |
| 33 | <span | 37 | <span |
| 34 | className={cn( | 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 | props.className, | 40 | props.className, |
| 37 | )} | 41 | )} |
| 38 | style={accentStyle} | 42 | style={accentStyle} |
| ... | @@ -48,24 +52,26 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { | ... | @@ -48,24 +52,26 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { |
| 48 | {props.statusDotClassName ? ( | 52 | {props.statusDotClassName ? ( |
| 49 | <span | 53 | <span |
| 50 | className={cn( | 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 | props.statusDotClassName, | 56 | props.statusDotClassName, |
| 53 | )} | 57 | )} |
| 58 | style={{ boxShadow: `0 0 0 2px ${indicatorBackground}` }} | ||
| 54 | aria-hidden | 59 | aria-hidden |
| 55 | /> | 60 | /> |
| 56 | ) : null} | 61 | ) : null} |
| 57 | {props.showBadge ? ( | 62 | {props.showBadge ? ( |
| 58 | <span | 63 | <span |
| 59 | className={cn( | 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 | props.accentColor | 66 | props.accentColor |
| 62 | ? "bg-[var(--provider-accent)] text-white" | 67 | ? "bg-[var(--provider-accent)] text-white" |
| 63 | : "bg-muted text-muted-foreground", | 68 | : "bg-muted text-muted-foreground", |
| 64 | props.badgeClassName, | 69 | props.badgeClassName, |
| 65 | )} | 70 | )} |
| 71 | style={{ borderColor: indicatorBackground }} | ||
| 66 | aria-hidden | 72 | aria-hidden |
| 67 | > | 73 | > |
| 68 | {providerInstanceInitials(props.displayName)} | 74 | {badgeContent === "initials" ? providerInstanceInitials(props.displayName) : null} |
| 69 | </span> | 75 | </span> |
| 70 | ) : null} | 76 | ) : null} |
| 71 | </span> | 77 | </span> |
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+102-12| ... | @@ -255,6 +255,7 @@ async function mountPicker(props: { | ... | @@ -255,6 +255,7 @@ async function mountPicker(props: { |
| 255 | providers?: ReadonlyArray<ServerProvider>; | 255 | providers?: ReadonlyArray<ServerProvider>; |
| 256 | settings?: UnifiedSettings; | 256 | settings?: UnifiedSettings; |
| 257 | triggerVariant?: "ghost" | "outline"; | 257 | triggerVariant?: "ghost" | "outline"; |
| 258 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | ||
| 258 | }) { | 259 | }) { |
| 259 | const host = document.createElement("div"); | 260 | const host = document.createElement("div"); |
| 260 | document.body.append(host); | 261 | document.body.append(host); |
| ... | @@ -277,6 +278,9 @@ async function mountPicker(props: { | ... | @@ -277,6 +278,9 @@ async function mountPicker(props: { |
| 277 | instanceEntries={instanceEntries} | 278 | instanceEntries={instanceEntries} |
| 278 | modelOptionsByInstance={modelOptionsByInstance} | 279 | modelOptionsByInstance={modelOptionsByInstance} |
| 279 | triggerVariant={props.triggerVariant} | 280 | triggerVariant={props.triggerVariant} |
| 281 | {...(props.getModelDisabledReason | ||
| 282 | ? { getModelDisabledReason: props.getModelDisabledReason } | ||
| 283 | : {})} | ||
| 280 | onInstanceModelChange={onInstanceModelChange} | 284 | onInstanceModelChange={onInstanceModelChange} |
| 281 | />, | 285 | />, |
| 282 | { container: host }, | 286 | { container: host }, |
| ... | @@ -461,7 +465,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -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 | localStorage.setItem( | 469 | localStorage.setItem( |
| 466 | "t3code:client-settings:v1", | 470 | "t3code:client-settings:v1", |
| 467 | JSON.stringify({ | 471 | JSON.stringify({ |
| ... | @@ -484,8 +488,21 @@ describe("ProviderModelPicker", () => { | ... | @@ -484,8 +488,21 @@ describe("ProviderModelPicker", () => { |
| 484 | 488 | ||
| 485 | await vi.waitFor(() => { | 489 | await vi.waitFor(() => { |
| 486 | const text = document.body.textContent ?? ""; | 490 | const text = document.body.textContent ?? ""; |
| 487 | // Should show locked provider label | ||
| 488 | expect(text).toContain("Claude"); | 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 | expect(getVisibleModelNames()).toEqual([ | 506 | expect(getVisibleModelNames()).toEqual([ |
| 490 | "Claude Sonnet 4.6", | 507 | "Claude Sonnet 4.6", |
| 491 | "Claude Opus 4.6", | 508 | "Claude Opus 4.6", |
| ... | @@ -559,7 +576,21 @@ describe("ProviderModelPicker", () => { | ... | @@ -559,7 +576,21 @@ describe("ProviderModelPicker", () => { |
| 559 | await page.getByRole("button").click(); | 576 | await page.getByRole("button").click(); |
| 560 | 577 | ||
| 561 | await vi.waitFor(() => { | 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 | expect(getModelPickerListText()).not.toContain("Codex Isolated"); | 594 | expect(getModelPickerListText()).not.toContain("Codex Isolated"); |
| 564 | expect( | 595 | expect( |
| 565 | document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]') | 596 | document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]') |
| ... | @@ -625,7 +656,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -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 | const providers: ReadonlyArray<ServerProvider> = [ | 660 | const providers: ReadonlyArray<ServerProvider> = [ |
| 630 | buildOpenCodeProvider([ | 661 | buildOpenCodeProvider([ |
| 631 | { | 662 | { |
| ... | @@ -658,14 +689,15 @@ describe("ProviderModelPicker", () => { | ... | @@ -658,14 +689,15 @@ describe("ProviderModelPicker", () => { |
| 658 | const trigger = document.querySelector<HTMLElement>( | 689 | const trigger = document.querySelector<HTMLElement>( |
| 659 | '[data-chat-provider-model-picker="true"]', | 690 | '[data-chat-provider-model-picker="true"]', |
| 660 | ); | 691 | ); |
| 661 | expect(trigger?.textContent).toContain("GitHub Copilot"); | ||
| 662 | expect(trigger?.textContent).toContain("Opus 4.5"); | 692 | expect(trigger?.textContent).toContain("Opus 4.5"); |
| 693 | expect(trigger?.textContent).not.toContain("GitHub Copilot"); | ||
| 663 | }); | 694 | }); |
| 664 | 695 | ||
| 665 | await page.getByRole("button").click(); | 696 | await page.getByRole("button").click(); |
| 666 | 697 | ||
| 667 | await vi.waitFor(() => { | 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 | } finally { | 702 | } finally { |
| 671 | await mounted.cleanup(); | 703 | await mounted.cleanup(); |
| ... | @@ -961,27 +993,30 @@ describe("ProviderModelPicker", () => { | ... | @@ -961,27 +993,30 @@ describe("ProviderModelPicker", () => { |
| 961 | expect(text).toContain("Claude Opus 4.6"); | 993 | expect(text).toContain("Claude Opus 4.6"); |
| 962 | }); | 994 | }); |
| 963 | 995 | ||
| 964 | const getFirstStarButton = () => { | 996 | const getFavoriteButton = () => { |
| 965 | const starButton = document.querySelector<HTMLButtonElement>( | 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 | 'button[aria-label*="favorites"]', | 1001 | 'button[aria-label*="favorites"]', |
| 967 | ); | 1002 | ); |
| 968 | expect(starButton).not.toBeNull(); | 1003 | expect(starButton).not.toBeNull(); |
| 969 | return starButton!; | 1004 | return starButton!; |
| 970 | }; | 1005 | }; |
| 971 | 1006 | ||
| 972 | const firstStar = getFirstStarButton(); | 1007 | const favoriteButton = getFavoriteButton(); |
| 973 | const initialAriaLabel = firstStar.getAttribute("aria-label"); | 1008 | const initialAriaLabel = favoriteButton.getAttribute("aria-label"); |
| 974 | expect( | 1009 | expect( |
| 975 | initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", | 1010 | initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", |
| 976 | ).toBe(true); | 1011 | ).toBe(true); |
| 977 | 1012 | ||
| 978 | await page.getByRole("button", { name: initialAriaLabel! }).first().click(); | 1013 | await userEvent.click(favoriteButton); |
| 979 | 1014 | ||
| 980 | const expectedAriaLabel = | 1015 | const expectedAriaLabel = |
| 981 | initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; | 1016 | initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; |
| 982 | 1017 | ||
| 983 | await vi.waitFor(() => { | 1018 | await vi.waitFor(() => { |
| 984 | expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel); | 1019 | expect(getFavoriteButton().getAttribute("aria-label")).toBe(expectedAriaLabel); |
| 985 | }); | 1020 | }); |
| 986 | } finally { | 1021 | } finally { |
| 987 | await mounted.cleanup(); | 1022 | await mounted.cleanup(); |
| ... | @@ -1050,6 +1085,38 @@ describe("ProviderModelPicker", () => { | ... | @@ -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 | it("dispatches callback with correct provider and model when selected", async () => { | 1120 | it("dispatches callback with correct provider and model when selected", async () => { |
| 1054 | const mounted = await mountPicker({ | 1121 | const mounted = await mountPicker({ |
| 1055 | activeInstanceId: CLAUDE_INSTANCE_ID, | 1122 | activeInstanceId: CLAUDE_INSTANCE_ID, |
| ... | @@ -1079,6 +1146,29 @@ describe("ProviderModelPicker", () => { | ... | @@ -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 | it("only shows codex spark when the server reports it", async () => { | 1172 | it("only shows codex spark when the server reports it", async () => { |
| 1083 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ | 1173 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ |
| 1084 | buildCodexProvider([ | 1174 | buildCodexProvider([ |
apps/web/src/components/chat/ProviderModelPicker.tsx+66-34| ... | @@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 41 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; | 41 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 42 | triggerClassName?: string; | 42 | triggerClassName?: string; |
| 43 | onOpenChange?: (open: boolean) => void; | 43 | onOpenChange?: (open: boolean) => void; |
| 44 | getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; | ||
| 44 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; | 45 | onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; |
| 45 | }) { | 46 | }) { |
| 46 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); | 47 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| ... | @@ -65,7 +66,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -65,7 +66,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 65 | selectedInstanceOptions.find((option) => option.slug === props.model) ?? | 66 | selectedInstanceOptions.find((option) => option.slug === props.model) ?? |
| 66 | selectedInstanceOptions[0]; | 67 | selectedInstanceOptions[0]; |
| 67 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; | 68 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; |
| 68 | const triggerSubtitle = selectedModel?.subProvider; | ||
| 69 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; | 69 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; |
| 70 | const duplicateDriverCount = props.instanceEntries.filter( | 70 | const duplicateDriverCount = props.instanceEntries.filter( |
| 71 | (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, | 71 | (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, |
| ... | @@ -86,6 +86,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -86,6 +86,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 86 | }; | 86 | }; |
| 87 | }, [isMenuOpen]); | 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 | const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { | 137 | const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => { |
| 90 | if (props.disabled) return; | 138 | if (props.disabled) return; |
| 91 | props.onInstanceModelChange(instanceId, model); | 139 | props.onInstanceModelChange(instanceId, model); |
| ... | @@ -110,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -110,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 110 | variant={props.triggerVariant ?? "ghost"} | 158 | variant={props.triggerVariant ?? "ghost"} |
| 111 | data-chat-provider-model-picker="true" | 159 | data-chat-provider-model-picker="true" |
| 112 | className={cn( | 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 | props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", | 162 | props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", |
| 115 | props.triggerClassName, | 163 | props.triggerClassName, |
| 116 | )} | 164 | )} |
| ... | @@ -118,12 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -118,12 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 118 | /> | 166 | /> |
| 119 | } | 167 | } |
| 120 | > | 168 | > |
| 121 | <span | 169 | <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"> |
| 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 | > | ||
| 127 | {activeEntry ? ( | 170 | {activeEntry ? ( |
| 128 | <ProviderInstanceIcon | 171 | <ProviderInstanceIcon |
| 129 | driverKind={activeEntry.driverKind} | 172 | driverKind={activeEntry.driverKind} |
| ... | @@ -132,42 +175,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -132,42 +175,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 132 | showBadge={showInstanceBadge} | 175 | showBadge={showInstanceBadge} |
| 133 | className={showInstanceBadge ? "size-5" : "size-4"} | 176 | className={showInstanceBadge ? "size-5" : "size-4"} |
| 134 | iconClassName={cn("size-4", props.activeProviderIconClassName)} | 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 | ) : null} | 184 | ) : null} |
| 138 | <Tooltip> | 185 | <Tooltip> |
| 139 | <TooltipTrigger | 186 | <TooltipTrigger render={<span className="min-w-0 flex-1 overflow-hidden truncate" />}> |
| 140 | render={ | 187 | {triggerTitle} |
| 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 | )} | ||
| 162 | </TooltipTrigger> | 188 | </TooltipTrigger> |
| 163 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> | 189 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> |
| 164 | </Tooltip> | 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 | </span> | 194 | </span> |
| 167 | </PopoverTrigger> | 195 | </PopoverTrigger> |
| 168 | <PopoverPopup | 196 | <PopoverPopup |
| 169 | align="start" | 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 | <ModelPickerContent | 201 | <ModelPickerContent |
| 173 | activeInstanceId={activeInstanceId} | 202 | activeInstanceId={activeInstanceId} |
| ... | @@ -179,6 +208,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -179,6 +208,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 179 | modelOptionsByInstance={props.modelOptionsByInstance} | 208 | modelOptionsByInstance={props.modelOptionsByInstance} |
| 180 | terminalOpen={props.terminalOpen ?? false} | 209 | terminalOpen={props.terminalOpen ?? false} |
| 181 | onRequestClose={() => setIsMenuOpen(false)} | 210 | onRequestClose={() => setIsMenuOpen(false)} |
| 211 | {...(props.getModelDisabledReason | ||
| 212 | ? { getModelDisabledReason: props.getModelDisabledReason } | ||
| 213 | : {})} | ||
| 182 | onInstanceModelChange={handleInstanceModelChange} | 214 | onInstanceModelChange={handleInstanceModelChange} |
| 183 | /> | 215 | /> |
| 184 | </PopoverPopup> | 216 | </PopoverPopup> |
apps/web/src/components/chat/providerIconUtils.ts+17-6| ... | @@ -28,14 +28,26 @@ export type ModelEsque = { | ... | @@ -28,14 +28,26 @@ export type ModelEsque = { |
| 28 | subProvider?: string | undefined; | 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 | export function getDisplayModelName( | 45 | export function getDisplayModelName( |
| 32 | model: ModelEsque, | 46 | model: ModelEsque, |
| 33 | options?: { preferShortName?: boolean }, | 47 | options?: { preferShortName?: boolean }, |
| 34 | ): string { | 48 | ): string { |
| 35 | if (options?.preferShortName && model.shortName) { | 49 | const name = options?.preferShortName && model.shortName ? model.shortName : model.name; |
| 36 | return model.shortName; | 50 | return stripLeadingQualifier(name, model.subProvider); |
| 37 | } | ||
| 38 | return model.name; | ||
| 39 | } | 51 | } |
| 40 | 52 | ||
| 41 | export function getTriggerDisplayModelName(model: ModelEsque): string { | 53 | export function getTriggerDisplayModelName(model: ModelEsque): string { |
| ... | @@ -43,6 +55,5 @@ export function getTriggerDisplayModelName(model: ModelEsque): string { | ... | @@ -43,6 +55,5 @@ export function getTriggerDisplayModelName(model: ModelEsque): string { |
| 43 | } | 55 | } |
| 44 | 56 | ||
| 45 | export function getTriggerDisplayModelLabel(model: ModelEsque): string { | 57 | export function getTriggerDisplayModelLabel(model: ModelEsque): string { |
| 46 | const title = getTriggerDisplayModelName(model); | 58 | return getTriggerDisplayModelName(model); |
| 47 | return model.subProvider ? `${model.subProvider} · ${title}` : title; | ||
| 48 | } | 59 | } |
apps/web/src/components/ui/combobox.tsx+3| ... | @@ -60,6 +60,7 @@ function ComboboxInput({ | ... | @@ -60,6 +60,7 @@ function ComboboxInput({ |
| 60 | showClear = false, | 60 | showClear = false, |
| 61 | startAddon, | 61 | startAddon, |
| 62 | size, | 62 | size, |
| 63 | unstyled = false, | ||
| 63 | ...props | 64 | ...props |
| 64 | }: Omit<ComboboxPrimitive.Input.Props, "size"> & { | 65 | }: Omit<ComboboxPrimitive.Input.Props, "size"> & { |
| 65 | inputClassName?: string; | 66 | inputClassName?: string; |
| ... | @@ -67,6 +68,7 @@ function ComboboxInput({ | ... | @@ -67,6 +68,7 @@ function ComboboxInput({ |
| 67 | showClear?: boolean; | 68 | showClear?: boolean; |
| 68 | startAddon?: React.ReactNode; | 69 | startAddon?: React.ReactNode; |
| 69 | size?: "sm" | "default" | "lg" | number; | 70 | size?: "sm" | "default" | "lg" | number; |
| 71 | unstyled?: boolean; | ||
| 70 | ref?: React.Ref<HTMLInputElement>; | 72 | ref?: React.Ref<HTMLInputElement>; |
| 71 | }) { | 73 | }) { |
| 72 | const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number; | 74 | const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number; |
| ... | @@ -97,6 +99,7 @@ function ComboboxInput({ | ... | @@ -97,6 +99,7 @@ function ComboboxInput({ |
| 97 | className={cn("has-disabled:opacity-100", inputClassName)} | 99 | className={cn("has-disabled:opacity-100", inputClassName)} |
| 98 | nativeInput | 100 | nativeInput |
| 99 | size={sizeValue} | 101 | size={sizeValue} |
| 102 | unstyled={unstyled} | ||
| 100 | /> | 103 | /> |
| 101 | } | 104 | } |
| 102 | {...props} | 105 | {...props} |
apps/web/src/components/ui/popover.tsx+3| ... | @@ -19,6 +19,7 @@ function PopoverTrigger({ className, children, ...props }: PopoverPrimitive.Trig | ... | @@ -19,6 +19,7 @@ function PopoverTrigger({ className, children, ...props }: PopoverPrimitive.Trig |
| 19 | function PopoverPopup({ | 19 | function PopoverPopup({ |
| 20 | children, | 20 | children, |
| 21 | className, | 21 | className, |
| 22 | viewportClassName, | ||
| 22 | side = "bottom", | 23 | side = "bottom", |
| 23 | align = "center", | 24 | align = "center", |
| 24 | sideOffset = 4, | 25 | sideOffset = 4, |
| ... | @@ -27,6 +28,7 @@ function PopoverPopup({ | ... | @@ -27,6 +28,7 @@ function PopoverPopup({ |
| 27 | anchor, | 28 | anchor, |
| 28 | ...props | 29 | ...props |
| 29 | }: PopoverPrimitive.Popup.Props & { | 30 | }: PopoverPrimitive.Popup.Props & { |
| 31 | viewportClassName?: string; | ||
| 30 | side?: PopoverPrimitive.Positioner.Props["side"]; | 32 | side?: PopoverPrimitive.Positioner.Props["side"]; |
| 31 | align?: PopoverPrimitive.Positioner.Props["align"]; | 33 | align?: PopoverPrimitive.Positioner.Props["align"]; |
| 32 | sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]; | 34 | sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]; |
| ... | @@ -61,6 +63,7 @@ function PopoverPopup({ | ... | @@ -61,6 +63,7 @@ function PopoverPopup({ |
| 61 | tooltipStyle | 63 | tooltipStyle |
| 62 | ? "py-1 [--viewport-inline-padding:--spacing(2)]" | 64 | ? "py-1 [--viewport-inline-padding:--spacing(2)]" |
| 63 | : "not-data-transitioning:overflow-y-auto", | 65 | : "not-data-transitioning:overflow-y-auto", |
| 66 | viewportClassName, | ||
| 64 | )} | 67 | )} |
| 65 | data-slot="popover-viewport" | 68 | data-slot="popover-viewport" |
| 66 | > | 69 | > |