authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-09 23:22:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 06:22:55+00:00
log31533466b09ba12ec520e61c7908841c0b55de01
treeaed12cb32576cd919238d1f2ae7b42bb20f894e4
parent7f741a56d176617d7f97865de24dbdf11ea3fd7e
signature Signed by PGP key B5690EEEBB952194

Model picker UI Improvements, Virtualize Model List (#3021)

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 ]);
36033603
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 onChangeActivePendingUserInputCustomAnswer3936 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;
490490
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 />
23522355
apps/web/src/components/chat/ModelListRow.tsx+79-62
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";1import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { StarIcon } from "lucide-react";3import { CheckIcon, StarIcon } from "lucide-react";
4import {4import {
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;
4143
42 return (44 const row = (
43 <ComboboxItem45 <ComboboxItem
44 hideIndicator46 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.useTriggerLabel62 ? 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";
6import { resolveSelectableModel } from "@t3tools/shared/model";6import { resolveSelectableModel } from "@t3tools/shared/model";
7import { LegendList, type LegendListRef } from "@legendapp/list/react";
7import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";8import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
8import { SearchIcon } from "lucide-react";9import { SearchIcon } from "lucide-react";
9import { ModelListRow } from "./ModelListRow";10import { ModelListRow } from "./ModelListRow";
10import { ModelPickerSidebar } from "./ModelPickerSidebar";11import { ModelPickerSidebar } from "./ModelPickerSidebar";
11import { isModelPickerNewModel } from "./modelPickerModelHighlights";12import { isModelPickerNewModel } from "./modelPickerModelHighlights";
12import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";13import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
13import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox";14import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox";
14import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";15import { ModelEsque } from "./providerIconUtils";
15import {16import {
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: {
213218
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 = showLockedInstanceSidebar228 disabled.add(entry.instanceId);
224 ? lockedInstanceEntries229 }
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: {
308331
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 ]);
335359
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 );
356383
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 );
370397
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]);
479501
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(() => {
490507 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]);
522515
523 return (516 return (
524 <TooltipProvider delay={0}>517 <TooltipProvider delay={0}>
525 <div518 <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 <ModelPickerSidebar524 <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 )}
549539
...@@ -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 autoHighlight546 autoHighlight
557 open547 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 <div567 <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 <ComboboxInput575 <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>
613615
614 {/* Model list */}616 {/* Model list */}
615 <div617 <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 <ModelListRow628 }
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 found666 No models found
apps/web/src/components/chat/ModelPickerSidebar.tsx+227-162
...@@ -1,9 +1,8 @@...@@ -1,9 +1,8 @@
1import { type ProviderInstanceId } from "@t3tools/contracts";1import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo, useMemo } from "react";2import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";
4import { Gemini, GithubCopilotIcon } from "../Icons";4import { Gemini, GithubCopilotIcon } from "../Icons";
5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { ScrollArea } from "../ui/scroll-area";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
9import type { ProviderInstanceEntry } from "../../providerInstances";8import 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}
3231
33const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm";
34const SELECTED_INDICATOR_CLASS =32const 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";
36const BADGE_BASE_CLASS =34const 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 ";
38const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `;36const 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 `;
4038
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). */
42const PICKER_TOOLTIP_SIDE = "left" as const;40const PICKER_TOOLTIP_SIDE = "left" as const;
41const PICKER_TOOLTIP_SIDE_OFFSET = 8;
43const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";42const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
4443
45export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {44export 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. Callers62 * Instance id values that should render the "new" sparkle badge. Callers
61 * pass the subset of default built-in ids they want flagged (custom63 * 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]);
7883
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 <ScrollArea108 <div
81 hideScrollbars109 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 <TooltipTrigger259 <TooltipTrigger
96 render={260 render={
97 <button261 <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 <TooltipPopup279 <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 Favorites285 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 <TooltipPopup313 <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.accentColor31 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";
3135
32 return (36 return (
33 <span37 <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 <span53 <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-hidden59 aria-hidden
55 />60 />
56 ) : null}61 ) : null}
57 {props.showBadge ? (62 {props.showBadge ? (
58 <span63 <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.accentColor66 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-hidden72 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 });
463467
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", () => {
484488
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();
560577
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 });
627658
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 });
664695
665 await page.getByRole("button").click();696 await page.getByRole("button").click();
666697
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 });
963995
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 };
9711006
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);
9771012
978 await page.getByRole("button", { name: initialAriaLabel! }).first().click();1013 await userEvent.click(favoriteButton);
9791014
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";
9821017
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 });
10521087
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 });
10811148
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]);
8888
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 <span169 <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 <ProviderInstanceIcon171 <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 <TooltipTrigger186 <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 <PopoverPopup196 <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 <ModelPickerContent201 <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};
3030
31function escapeRegExp(value: string): string {
32 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33}
34
35function 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
31export function getDisplayModelName(45export 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}
4052
41export function getTriggerDisplayModelName(model: ModelEsque): string {53export 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}
4456
45export function getTriggerDisplayModelLabel(model: ModelEsque): string {57export 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 ...props64 ...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 nativeInput100 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
19function PopoverPopup({19function 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 ...props29 ...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 tooltipStyle63 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 >