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) {
36013601 composerRef,
36023602 ]);
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
36043621 const onProviderModelSelect = useCallback(
36053622 (instanceId: ProviderInstanceId, model: string) => {
36063623 if (!activeThread) return;
......@@ -3919,6 +3936,7 @@ export default function ChatView(props: ChatViewProps) {
39193936 onChangeActivePendingUserInputCustomAnswer
39203937 }
39213938 onProviderModelSelect={onProviderModelSelect}
3939 getModelDisabledReason={getModelDisabledReason}
39223940 toggleInteractionMode={toggleInteractionMode}
39233941 handleRuntimeModeChange={handleRuntimeModeChange}
39243942 handleInteractionModeChange={handleInteractionModeChange}
apps/web/src/components/chat/ChatComposer.tsx+3
......@@ -489,6 +489,7 @@ export interface ChatComposerProps {
489489 ) => void;
490490
491491 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;
492 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
492493 toggleInteractionMode: () => void;
493494 handleRuntimeModeChange: (mode: RuntimeMode) => void;
494495 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
......@@ -563,6 +564,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
563564 onPreviousActivePendingUserInputQuestion,
564565 onChangeActivePendingUserInputCustomAnswer,
565566 onProviderModelSelect,
567 getModelDisabledReason,
566568 toggleInteractionMode,
567569 handleRuntimeModeChange,
568570 handleInteractionModeChange,
......@@ -2347,6 +2349,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23472349 onOpenChange={(open) => {
23482350 setIsComposerModelPickerOpen(open);
23492351 }}
2352 getModelDisabledReason={getModelDisabledReason}
23502353 onInstanceModelChange={onProviderModelSelect}
23512354 />
23522355
apps/web/src/components/chat/ModelListRow.tsx+79-62
......@@ -1,6 +1,6 @@
11import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts";
22import { memo } from "react";
3import { StarIcon } from "lucide-react";
3import { CheckIcon, StarIcon } from "lucide-react";
44import {
55 getDisplayModelName,
66 getTriggerDisplayModelLabel,
......@@ -27,11 +27,13 @@ export const ModelListRow = memo(function ModelListRow(props: {
2727 providerDisplayName: string;
2828 providerAccentColor?: string | undefined;
2929 isFavorite: boolean;
30 isSelected: boolean;
3031 showProvider: boolean;
3132 preferShortName?: boolean;
3233 useTriggerLabel?: boolean;
3334 showNewBadge?: boolean;
3435 jumpLabel?: string | null;
36 disabledReason?: string | null;
3537 onToggleFavorite: () => void;
3638}) {
3739 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null;
......@@ -39,85 +41,100 @@ export const ModelListRow = memo(function ModelListRow(props: {
3941 ? `${props.providerDisplayName} · ${props.model.subProvider}`
4042 : props.providerDisplayName;
4143
42 return (
44 const row = (
4345 <ComboboxItem
4446 hideIndicator
4547 index={props.index}
4648 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"
4851 className={cn(
49 "w-full cursor-pointer rounded px-3 py-2 transition-colors group",
50 "data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground",
52 "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]",
53 "data-highlighted:bg-muted/56 data-selected:bg-transparent data-selected:text-foreground data-selected:ring-0",
54 props.disabledReason &&
55 "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
5156 )}
5257 >
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
7958 <div className="min-w-0 flex-1 text-left">
80 <div className="flex items-center justify-between gap-2 min-w-0">
81 <div className="text-xs font-medium leading-snug flex items-center gap-2 min-w-0">
82 <span className="truncate">
83 {props.useTriggerLabel
84 ? getTriggerDisplayModelLabel(props.model)
85 : getDisplayModelName(
86 props.model,
87 props.preferShortName ? { preferShortName: true } : undefined,
88 )}
89 </span>
90 {props.showNewBadge ? (
91 <span
92 className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200"
93 aria-label="New model"
94 >
95 New
96 </span>
97 ) : null}
59 <div className="flex min-w-0 items-center gap-2">
60 <div className="min-w-0 truncate text-xs font-medium leading-snug">
61 {props.useTriggerLabel
62 ? getTriggerDisplayModelLabel(props.model)
63 : getDisplayModelName(
64 props.model,
65 props.preferShortName ? { preferShortName: true } : undefined,
66 )}
9867 </div>
99 {props.jumpLabel ? (
100 <Kbd className="h-4 min-w-0 shrink-0 rounded-sm px-1.5 text-[10px]">
101 {props.jumpLabel}
102 </Kbd>
68 {props.isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}
69 {props.showNewBadge ? (
70 <span
71 className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200"
72 aria-label="New model"
73 >
74 New
75 </span>
10376 ) : null}
10477 </div>
10578 {props.showProvider && (
106 <div className="flex items-center gap-1 mt-0.5">
79 <div className="mt-1 flex items-center gap-1.5">
10780 {ProviderIcon ? <ProviderIcon className="size-3 shrink-0" /> : null}
108 {props.providerAccentColor ? (
109 <span
110 className="size-1.5 shrink-0 rounded-full"
111 style={{ backgroundColor: props.providerAccentColor }}
112 aria-hidden
113 />
114 ) : null}
115 <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate">
81 <span className="truncate text-xs font-normal leading-snug text-muted-foreground/70">
11682 {providerLabel}
11783 </span>
11884 </div>
11985 )}
12086 </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>
121125 </ComboboxItem>
122126 );
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 );
123140});
apps/web/src/components/chat/ModelPickerContent.tsx+173-154
......@@ -4,14 +4,15 @@ import {
44 type ResolvedKeybindingsConfig,
55} from "@t3tools/contracts";
66import { resolveSelectableModel } from "@t3tools/shared/model";
7import { LegendList, type LegendListRef } from "@legendapp/list/react";
78import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
89import { SearchIcon } from "lucide-react";
910import { ModelListRow } from "./ModelListRow";
1011import { ModelPickerSidebar } from "./ModelPickerSidebar";
1112import { isModelPickerNewModel } from "./modelPickerModelHighlights";
1213import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
13import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox";
14import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
14import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox";
15import { ModelEsque } from "./providerIconUtils";
1516import {
1617 modelPickerJumpCommandForIndex,
1718 modelPickerJumpIndexFromCommand,
......@@ -81,17 +82,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
8182 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
8283 terminalOpen: boolean;
8384 onRequestClose?: () => void;
85 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
8486 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
8587}) {
8688 const {
8789 keybindings: providedKeybindings,
8890 modelOptionsByInstance,
8991 instanceEntries,
92 getModelDisabledReason,
9093 onInstanceModelChange,
9194 } = props;
9295 const [searchQuery, setSearchQuery] = useState("");
96 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
97 const [showBottomScrollFade, setShowBottomScrollFade] = useState(false);
9398 const searchInputRef = useRef<HTMLInputElement>(null);
94 const listRegionRef = useRef<HTMLDivElement>(null);
99 const modelListRef = useRef<LegendListRef | null>(null);
95100 const highlightedModelKeyRef = useRef<string | null>(null);
96101 const favorites = useSettings((s) => s.favorites ?? []);
97102 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
......@@ -213,16 +218,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
213218
214219 const isLocked = props.lockedProvider !== null;
215220 const isSearching = searchQuery.trim().length > 0;
216 const lockedInstanceEntries = useMemo(
217 () =>
218 props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [],
219 [instanceEntries, matchesLockedProvider, props.lockedProvider],
220 );
221 const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1;
222 const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar);
223 const sidebarInstanceEntries = showLockedInstanceSidebar
224 ? lockedInstanceEntries
225 : instanceEntries;
221 const lockedDisabledInstanceIds = useMemo(() => {
222 if (!isLocked) {
223 return undefined;
224 }
225 const disabled = new Set<ProviderInstanceId>();
226 for (const entry of instanceEntries) {
227 if (!matchesLockedProvider(entry)) {
228 disabled.add(entry.instanceId);
229 }
230 }
231 return disabled;
232 }, [instanceEntries, isLocked, matchesLockedProvider]);
233 const sidebarInstanceEntries = useMemo(() => {
234 if (!isLocked) {
235 return instanceEntries;
236 }
237 const available: ProviderInstanceEntry[] = [];
238 const disabled: ProviderInstanceEntry[] = [];
239 for (const entry of instanceEntries) {
240 if (matchesLockedProvider(entry)) {
241 available.push(entry);
242 } else {
243 disabled.push(entry);
244 }
245 }
246 return [...available, ...disabled];
247 }, [instanceEntries, isLocked, matchesLockedProvider]);
248 const showSidebar = !isSearching && sidebarInstanceEntries.length > 0;
226249 const instanceOrder = useMemo(
227250 () => instanceEntries.map((entry) => entry.instanceId),
228251 [instanceEntries],
......@@ -308,7 +331,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
308331
309332 if (props.lockedProvider !== null) {
310333 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 {
312337 result = result.filter((m) => m.instanceId === selectedInstanceId);
313338 }
314339 } else if (selectedInstanceId === "favorites") {
......@@ -329,12 +354,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
329354 matchesLockedProvider,
330355 props.lockedProvider,
331356 searchQuery,
332 showLockedInstanceSidebar,
333357 selectedInstanceId,
334358 ]);
335359
336360 const handleModelSelect = useCallback(
337361 (modelSlug: string, instanceId: ProviderInstanceId) => {
362 if (getModelDisabledReason?.(instanceId, modelSlug)) {
363 return;
364 }
338365 const options = modelOptionsByInstance.get(instanceId);
339366 if (!options) {
340367 return;
......@@ -351,7 +378,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
351378 onInstanceModelChange(instanceId, resolvedModel);
352379 }
353380 },
354 [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange],
381 [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange],
355382 );
356383
357384 const toggleFavorite = useCallback(
......@@ -368,39 +395,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
368395 [favorites, updateSettings],
369396 );
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 ]);
390398 const modelJumpCommandByKey = useMemo(() => {
391399 const mapping = new Map<
392400 string,
393401 NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>>
394402 >();
395 for (const [visibleModelIndex, model] of filteredModels.entries()) {
396 const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex);
403 let selectableModelIndex = 0;
404 for (const model of filteredModels) {
405 if (getModelDisabledReason?.(model.instanceId, model.slug)) {
406 continue;
407 }
408 const jumpCommand = modelPickerJumpCommandForIndex(selectableModelIndex);
397409 if (!jumpCommand) {
398410 return mapping;
399411 }
400412 mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand);
413 selectableModelIndex += 1;
401414 }
402415 return mapping;
403 }, [filteredModels]);
416 }, [filteredModels, getModelDisabledReason]);
404417 const modelJumpModelKeys = useMemo(
405418 () => [...modelJumpCommandByKey.keys()],
406419 [modelJumpCommandByKey],
......@@ -418,6 +431,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
418431 new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)),
419432 [filteredModels],
420433 );
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 }, []);
421443 const modelJumpShortcutContext = useMemo(
422444 () =>
423445 ({
......@@ -478,72 +500,40 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
478500 }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]);
479501
480502 useLayoutEffect(() => {
481 const listRegion = listRegionRef.current;
482 if (!listRegion) {
483 return;
484 }
485
486 let cancelled = false;
487 let frame = 0;
503 setShowTopScrollFade(false);
504 setShowBottomScrollFade(filteredModelKeys.length > 5);
488505 let nestedFrame = 0;
489 let timeout = 0;
490
491 const measureScrollArea = () => {
492 if (cancelled) {
493 return;
494 }
495 const viewport = listRegion.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]');
496 if (!viewport || viewport.scrollHeight <= viewport.clientHeight) {
497 return;
498 }
499 const originalScrollTop = viewport.scrollTop;
500 const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
501 if (maxScrollTop <= 0) {
502 return;
503 }
504 viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop);
505 viewport.scrollTop = originalScrollTop;
506 };
507
508 queueMicrotask(measureScrollArea);
509 frame = window.requestAnimationFrame(() => {
510 measureScrollArea();
511 nestedFrame = window.requestAnimationFrame(measureScrollArea);
506 const frame = window.requestAnimationFrame(() => {
507 updateModelListScrollFades();
508 nestedFrame = window.requestAnimationFrame(updateModelListScrollFades);
512509 });
513 timeout = window.setTimeout(measureScrollArea, 0);
514
515510 return () => {
516 cancelled = true;
517511 window.cancelAnimationFrame(frame);
518512 window.cancelAnimationFrame(nestedFrame);
519 window.clearTimeout(timeout);
520513 };
521 }, [filteredModelKeys]);
514 }, [filteredModelKeys, updateModelListScrollFades]);
522515
523516 return (
524517 <TooltipProvider delay={0}>
525518 <div
526 className={cn(
527 "relative flex h-screen max-h-96 w-screen max-w-100 overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
528 isLocked && !showLockedInstanceSidebar ? "flex-col" : "flex-row",
529 )}
519 className="relative flex h-screen max-h-96 w-screen max-w-100 flex-row overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"
520 data-model-picker-content="true"
530521 >
531 {/* Locked provider header (only shown in locked mode) */}
532 {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && (
533 <div className="flex items-center gap-2 px-4 py-3 border-b">
534 <LockedProviderIcon className="size-5 shrink-0" />
535 <span className="font-medium text-sm">{lockedHeaderLabel}</span>
536 </div>
537 )}
538
539 {/* Sidebar (only in unlocked mode) */}
522 {/* Sidebar */}
540523 {showSidebar && (
541524 <ModelPickerSidebar
542525 selectedInstanceId={selectedInstanceId}
543526 onSelectInstance={handleSelectInstance}
544527 instanceEntries={sidebarInstanceEntries}
545 showFavorites={!isLocked}
546 showComingSoon={!isLocked}
528 showFavorites
529 showComingSoon
530 {...(lockedDisabledInstanceIds
531 ? {
532 disabledInstanceIds: lockedDisabledInstanceIds,
533 getDisabledInstanceTooltip: (entry: ProviderInstanceEntry) =>
534 `${entry.displayName} is unavailable in this thread. Start a new thread to switch providers.`,
535 }
536 : {})}
547537 />
548538 )}
549539
......@@ -555,9 +545,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
555545 filter={null}
556546 autoHighlight
557547 open
548 virtualized
558549 value={`${props.activeInstanceId}:${props.model}`}
559 onItemHighlighted={(modelKey) => {
550 onItemHighlighted={(modelKey, eventDetails) => {
560551 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 }
561558 }}
562559 onValueChange={(modelKey) => {
563560 if (typeof modelKey !== "string") {
......@@ -569,79 +566,101 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
569566 >
570567 <div
571568 className={cn(
572 "flex min-h-0 flex-1 flex-col overflow-hidden",
573 isLocked && !showLockedInstanceSidebar ? "min-w-0" : showSidebar && "border-l",
569 "flex min-h-0 flex-1 flex-col overflow-hidden bg-muted/40",
570 showSidebar && "border-l",
574571 )}
575572 >
576573 {/* Search bar */}
577 <div className="border-b px-3 py-2">
578 <ComboboxInput
579 ref={searchInputRef}
580 className="[&_input]:font-sans rounded-md"
581 inputClassName="border-0 shadow-none ring-0 focus-visible:ring-0"
582 placeholder="Search models..."
583 showTrigger={false}
584 startAddon={<SearchIcon className="size-4 shrink-0 text-muted-foreground/50" />}
585 value={searchQuery}
586 onChange={(e) => setSearchQuery(e.target.value)}
587 onKeyDown={(e) => {
588 if (e.key === "Escape") {
589 e.preventDefault();
590 e.stopPropagation();
591 props.onRequestClose?.();
592 return;
574 <div className="px-4 pt-2.5">
575 <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring">
576 <ComboboxInput
577 ref={searchInputRef}
578 className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5"
579 inputClassName="rounded-none bg-transparent text-sm"
580 placeholder="Search models..."
581 showTrigger={false}
582 startAddon={
583 <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" />
593584 }
594 if (e.key === "Enter" && highlightedModelKeyRef.current) {
595 (
596 e as typeof e & { preventBaseUIHandler?: () => void }
597 ).preventBaseUIHandler?.();
598 e.preventDefault();
585 value={searchQuery}
586 onChange={(e) => setSearchQuery(e.target.value)}
587 onKeyDown={(e) => {
588 if (e.key === "Escape") {
589 e.preventDefault();
590 e.stopPropagation();
591 props.onRequestClose?.();
592 return;
593 }
594 if (e.key === "Enter" && highlightedModelKeyRef.current) {
595 (
596 e as typeof e & { preventBaseUIHandler?: () => void }
597 ).preventBaseUIHandler?.();
598 e.preventDefault();
599 e.stopPropagation();
600 const { instanceId, slug } = splitInstanceModelKey(
601 highlightedModelKeyRef.current,
602 );
603 handleModelSelect(slug, instanceId);
604 return;
605 }
599606 e.stopPropagation();
600 const { instanceId, slug } = splitInstanceModelKey(
601 highlightedModelKeyRef.current,
602 );
603 handleModelSelect(slug, instanceId);
604 return;
605 }
606 e.stopPropagation();
607 }}
608 onMouseDown={(e) => e.stopPropagation()}
609 onTouchStart={(e) => e.stopPropagation()}
610 size="sm"
611 />
607 }}
608 onMouseDown={(e) => e.stopPropagation()}
609 onTouchStart={(e) => e.stopPropagation()}
610 size="sm"
611 unstyled
612 />
613 </div>
612614 </div>
613615
614616 {/* Model list */}
615 <div
616 ref={listRegionRef}
617 className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40"
618 >
619 <ComboboxList className="model-picker-list size-full divide-y px-2 py-1">
620 {filteredModelKeys.map((modelKey, index) => {
621 const model = filteredModelByKey.get(modelKey);
622 if (!model) {
623 return null;
624 }
625 return (
626 <ModelListRow
627 key={modelKey}
628 index={index}
629 model={model}
630 instanceId={model.instanceId}
631 driverKind={model.driverKind}
632 providerDisplayName={model.instanceDisplayName}
633 providerAccentColor={model.instanceAccentColor}
634 isFavorite={favoritesSet.has(modelKey)}
635 showProvider={!isLocked || showLockedInstanceSidebar}
636 preferShortName={!isLocked}
637 useTriggerLabel={isLocked && !showLockedInstanceSidebar}
638 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
639 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
640 onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
641 />
642 );
643 })}
644 </ComboboxList>
617 <div className="relative min-h-0 flex-1 overflow-hidden">
618 <ComboboxListVirtualized className="model-picker-list size-full min-w-0 p-0">
619 <LegendList<string>
620 ref={modelListRef}
621 data={filteredModelKeys}
622 extraData={favoritesSet}
623 keyExtractor={(modelKey) => modelKey}
624 renderItem={({ item: modelKey, index }) => {
625 const model = filteredModelByKey.get(modelKey);
626 if (!model) {
627 return null;
628 }
629 const disabledReason =
630 getModelDisabledReason?.(model.instanceId, model.slug) ?? null;
631 return (
632 <ModelListRow
633 key={modelKey}
634 index={index}
635 model={model}
636 instanceId={model.instanceId}
637 driverKind={model.driverKind}
638 providerDisplayName={model.instanceDisplayName}
639 providerAccentColor={model.instanceAccentColor}
640 isFavorite={favoritesSet.has(modelKey)}
641 isSelected={modelKey === `${props.activeInstanceId}:${props.model}`}
642 showProvider
643 preferShortName={!isLocked}
644 useTriggerLabel={false}
645 showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)}
646 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
647 disabledReason={disabledReason}
648 onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
649 />
650 );
651 }}
652 estimatedItemSize={60}
653 drawDistance={480}
654 recycleItems
655 onLayout={updateModelListScrollFades}
656 onScroll={updateModelListScrollFades}
657 className={cn(
658 "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]",
659 showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]",
660 showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]",
661 )}
662 />
663 </ComboboxListVirtualized>
645664 </div>
646665 <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug">
647666 No models found
apps/web/src/components/chat/ModelPickerSidebar.tsx+227-162
......@@ -1,9 +1,8 @@
11import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo, useMemo } from "react";
2import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
33import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";
44import { Gemini, GithubCopilotIcon } from "../Icons";
55import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { ScrollArea } from "../ui/scroll-area";
76import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
87import { cn } from "~/lib/utils";
98import type { ProviderInstanceEntry } from "../../providerInstances";
......@@ -30,9 +29,8 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string {
3029 return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`;
3130}
3231
33const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm";
3432const 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";
3634const BADGE_BASE_CLASS =
3735 "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 ";
3836const 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 `;
4038
4139/** Opens toward the rail so the list stays readable (not over the model names). */
4240const PICKER_TOOLTIP_SIDE = "left" as const;
41const PICKER_TOOLTIP_SIDE_OFFSET = 8;
4342const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
4443
4544export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
......@@ -56,6 +55,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
5655 showFavorites?: boolean;
5756 /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */
5857 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;
5961 /**
6062 * Instance id values that should render the "new" sparkle badge. Callers
6163 * pass the subset of default built-in ids they want flagged (custom
......@@ -68,6 +70,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
6870 };
6971 const showFavorites = props.showFavorites ?? true;
7072 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);
7176 const duplicateDriverCounts = useMemo(() => {
7277 const counts = new Map<string, number>();
7378 for (const entry of props.instanceEntries) {
......@@ -76,188 +81,248 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
7681 return counts;
7782 }, [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
79107 return (
80 <ScrollArea
81 hideScrollbars
82 scrollFade
83 className="w-12 shrink-0 border-r bg-muted/30"
108 <div
109 className="w-12 shrink-0 overflow-hidden border-r bg-muted/30"
84110 data-model-picker-sidebar="true"
85111 >
86 <div className="flex min-h-full flex-col gap-1 p-1">
87 {/* Favorites section */}
88 {showFavorites ? (
89 <div className="pb-1 mb-1 border-b">
90 <div className="relative w-full">
91 {props.selectedInstanceId === "favorites" && (
92 <div className={SELECTED_INDICATOR_CLASS} />
112 <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
113 <div
114 ref={sidebarContentRef}
115 className="relative flex min-h-full flex-col gap-1 px-1 pb-1 pt-0.5"
116 >
117 {selectedIndicatorTop !== null ? (
118 <div
119 data-model-picker-selected-indicator="true"
120 className={cn(
121 SELECTED_INDICATOR_CLASS,
122 "right-0 translate-y-0 transition-[top] duration-200 ease-out",
93123 )}
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) */}
94258 <Tooltip>
95259 <TooltipTrigger
96260 render={
97 <button
98 className={cn(
99 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted",
100 props.selectedInstanceId === "favorites" && SELECTED_BUTTON_CLASS,
101 )}
102 onClick={() => handleSelect("favorites")}
103 type="button"
104 data-model-picker-provider="favorites"
105 aria-label="Favorites"
106 >
107 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />
108 </button>
261 <span className="relative block w-full">
262 <button
263 className={cn(
264 "relative isolate flex w-full aspect-square items-center justify-center rounded-md opacity-50 cursor-not-allowed transition-colors hover:bg-transparent",
265 )}
266 disabled
267 type="button"
268 data-model-picker-provider="gemini-coming-soon"
269 aria-label="Gemini — coming soon"
270 >
271 <Gemini className="size-5 text-muted-foreground/85" aria-hidden />
272 <span className={SOON_BADGE_CLASS} aria-hidden>
273 <Clock3Icon className="size-2" />
274 </span>
275 </button>
276 </span>
109277 }
110278 />
111279 <TooltipPopup
112280 side={PICKER_TOOLTIP_SIDE}
281 sideOffset={PICKER_TOOLTIP_SIDE_OFFSET}
113282 align="center"
114283 className={PICKER_TOOLTIP_CLASS}
115284 >
116 Favorites
285 Gemini — Coming soon
117286 </TooltipPopup>
118287 </Tooltip>
119 </div>
120 </div>
121 ) : null}
122
123 {/* Instance buttons (one per configured instance — built-in + custom) */}
124 {props.instanceEntries.map((entry) => {
125 const isDisabled = !entry.isAvailable || entry.status !== "ready";
126 const isSelected = props.selectedInstanceId === entry.instanceId;
127 const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false;
128 const showInstanceBadge =
129 Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1;
130
131 const tooltip = isDisabled
132 ? describeUnavailableInstance(entry)
133 : showNewBadge
134 ? `${entry.displayName} — New`
135 : entry.displayName;
136
137 const button = (
138 <button
139 data-model-picker-provider={entry.instanceId}
140 className={cn(
141 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted",
142 isSelected && SELECTED_BUTTON_CLASS,
143 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
144 )}
145 data-provider-accent-color={entry.accentColor}
146 onClick={() => !isDisabled && handleSelect(entry.instanceId)}
147 disabled={isDisabled}
148 type="button"
149 aria-label={
150 isDisabled
151 ? tooltip
152 : showNewBadge
153 ? `${entry.displayName}, new`
154 : entry.displayName
155 }
156 >
157 <ProviderInstanceIcon
158 driverKind={entry.driverKind}
159 displayName={entry.displayName}
160 accentColor={entry.accentColor}
161 showBadge={showInstanceBadge}
162 className="size-6"
163 iconClassName="size-5"
164 />
165 {showNewBadge ? (
166 <span className={NEW_BADGE_CLASS} aria-hidden>
167 <SparklesIcon className="size-2" />
168 </span>
169 ) : null}
170 </button>
171 );
172
173 const trigger = isDisabled ? (
174 <span className="relative block w-full">{button}</span>
175 ) : (
176 button
177 );
178
179 return (
180 <div key={entry.instanceId} className="relative w-full">
181 {isSelected && <div className={SELECTED_INDICATOR_CLASS} />}
288 {/* Github Copilot button (coming soon) */}
182289 <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 />
184313 <TooltipPopup
185314 side={PICKER_TOOLTIP_SIDE}
315 sideOffset={PICKER_TOOLTIP_SIDE_OFFSET}
186316 align="center"
187317 className={PICKER_TOOLTIP_CLASS}
188318 >
189 {tooltip}
319 Github Copilot — Coming soon
190320 </TooltipPopup>
191321 </Tooltip>
192 </div>
193 );
194 })}
195
196 {showComingSoon ? (
197 <>
198 {/* Gemini button (coming soon) */}
199 <Tooltip>
200 <TooltipTrigger
201 render={
202 <span className="relative block w-full">
203 <button
204 className={cn(
205 "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent",
206 )}
207 disabled
208 type="button"
209 data-model-picker-provider="gemini-coming-soon"
210 aria-label="Gemini — coming soon"
211 >
212 <Gemini className="size-5 text-muted-foreground/85" aria-hidden />
213 <span className={SOON_BADGE_CLASS} aria-hidden>
214 <Clock3Icon className="size-2" />
215 </span>
216 </button>
217 </span>
218 }
219 />
220 <TooltipPopup
221 side={PICKER_TOOLTIP_SIDE}
222 align="center"
223 className={PICKER_TOOLTIP_CLASS}
224 >
225 Gemini — Coming soon
226 </TooltipPopup>
227 </Tooltip>
228 {/* Github Copilot button (coming soon) */}
229 <Tooltip>
230 <TooltipTrigger
231 render={
232 <span className="relative block w-full">
233 <button
234 className={cn(
235 "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent",
236 )}
237 disabled
238 type="button"
239 data-model-picker-provider="github-copilot-coming-soon"
240 aria-label="Github Copilot — coming soon"
241 >
242 <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden />
243 <span className={SOON_BADGE_CLASS} aria-hidden>
244 <Clock3Icon className="size-2" />
245 </span>
246 </button>
247 </span>
248 }
249 />
250 <TooltipPopup
251 side={PICKER_TOOLTIP_SIDE}
252 align="center"
253 className={PICKER_TOOLTIP_CLASS}
254 >
255 Github Copilot — Coming soon
256 </TooltipPopup>
257 </Tooltip>
258 </>
259 ) : null}
322 </>
323 ) : null}
324 </div>
260325 </div>
261 </ScrollArea>
326 </div>
262327 );
263328});
apps/web/src/components/chat/ProviderInstanceIcon.tsx+10-4
......@@ -19,20 +19,24 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
1919 displayName: string;
2020 accentColor?: string | undefined;
2121 showBadge?: boolean;
22 badgeContent?: "initials" | "none";
2223 className?: string;
2324 iconClassName?: string;
2425 badgeClassName?: string;
2526 statusDotClassName?: string;
27 indicatorBackground?: string;
2628}) {
2729 const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null;
30 const indicatorBackground = props.indicatorBackground ?? "var(--card)";
2831 const accentStyle = props.accentColor
2932 ? ({ "--provider-accent": props.accentColor } as CSSProperties)
3033 : undefined;
34 const badgeContent = props.badgeContent ?? "initials";
3135
3236 return (
3337 <span
3438 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",
3640 props.className,
3741 )}
3842 style={accentStyle}
......@@ -48,24 +52,26 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: {
4852 {props.statusDotClassName ? (
4953 <span
5054 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",
5256 props.statusDotClassName,
5357 )}
58 style={{ boxShadow: `0 0 0 2px ${indicatorBackground}` }}
5459 aria-hidden
5560 />
5661 ) : null}
5762 {props.showBadge ? (
5863 <span
5964 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",
6166 props.accentColor
6267 ? "bg-[var(--provider-accent)] text-white"
6368 : "bg-muted text-muted-foreground",
6469 props.badgeClassName,
6570 )}
71 style={{ borderColor: indicatorBackground }}
6672 aria-hidden
6773 >
68 {providerInstanceInitials(props.displayName)}
74 {badgeContent === "initials" ? providerInstanceInitials(props.displayName) : null}
6975 </span>
7076 ) : null}
7177 </span>
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+102-12
......@@ -255,6 +255,7 @@ async function mountPicker(props: {
255255 providers?: ReadonlyArray<ServerProvider>;
256256 settings?: UnifiedSettings;
257257 triggerVariant?: "ghost" | "outline";
258 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
258259}) {
259260 const host = document.createElement("div");
260261 document.body.append(host);
......@@ -277,6 +278,9 @@ async function mountPicker(props: {
277278 instanceEntries={instanceEntries}
278279 modelOptionsByInstance={modelOptionsByInstance}
279280 triggerVariant={props.triggerVariant}
281 {...(props.getModelDisabledReason
282 ? { getModelDisabledReason: props.getModelDisabledReason }
283 : {})}
280284 onInstanceModelChange={onInstanceModelChange}
281285 />,
282286 { container: host },
......@@ -461,7 +465,7 @@ describe("ProviderModelPicker", () => {
461465 }
462466 });
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 () => {
465469 localStorage.setItem(
466470 "t3code:client-settings:v1",
467471 JSON.stringify({
......@@ -484,8 +488,21 @@ describe("ProviderModelPicker", () => {
484488
485489 await vi.waitFor(() => {
486490 const text = document.body.textContent ?? "";
487 // Should show locked provider label
488491 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);
489506 expect(getVisibleModelNames()).toEqual([
490507 "Claude Sonnet 4.6",
491508 "Claude Opus 4.6",
......@@ -559,7 +576,21 @@ describe("ProviderModelPicker", () => {
559576 await page.getByRole("button").click();
560577
561578 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);
563594 expect(getModelPickerListText()).not.toContain("Codex Isolated");
564595 expect(
565596 document.querySelector<HTMLElement>('[data-model-picker-provider="codex_personal"]')
......@@ -625,7 +656,7 @@ describe("ProviderModelPicker", () => {
625656 }
626657 });
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 () => {
629660 const providers: ReadonlyArray<ServerProvider> = [
630661 buildOpenCodeProvider([
631662 {
......@@ -658,14 +689,15 @@ describe("ProviderModelPicker", () => {
658689 const trigger = document.querySelector<HTMLElement>(
659690 '[data-chat-provider-model-picker="true"]',
660691 );
661 expect(trigger?.textContent).toContain("GitHub Copilot");
662692 expect(trigger?.textContent).toContain("Opus 4.5");
693 expect(trigger?.textContent).not.toContain("GitHub Copilot");
663694 });
664695
665696 await page.getByRole("button").click();
666697
667698 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");
669701 });
670702 } finally {
671703 await mounted.cleanup();
......@@ -961,27 +993,30 @@ describe("ProviderModelPicker", () => {
961993 expect(text).toContain("Claude Opus 4.6");
962994 });
963995
964 const getFirstStarButton = () => {
965 const starButton = document.querySelector<HTMLButtonElement>(
996 const getFavoriteButton = () => {
997 const modelRow = Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find(
998 (row) => row.textContent?.includes("Claude Opus 4.6"),
999 );
1000 const starButton = modelRow?.querySelector<HTMLButtonElement>(
9661001 'button[aria-label*="favorites"]',
9671002 );
9681003 expect(starButton).not.toBeNull();
9691004 return starButton!;
9701005 };
9711006
972 const firstStar = getFirstStarButton();
973 const initialAriaLabel = firstStar.getAttribute("aria-label");
1007 const favoriteButton = getFavoriteButton();
1008 const initialAriaLabel = favoriteButton.getAttribute("aria-label");
9741009 expect(
9751010 initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites",
9761011 ).toBe(true);
9771012
978 await page.getByRole("button", { name: initialAriaLabel! }).first().click();
1013 await userEvent.click(favoriteButton);
9791014
9801015 const expectedAriaLabel =
9811016 initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites";
9821017
9831018 await vi.waitFor(() => {
984 expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel);
1019 expect(getFavoriteButton().getAttribute("aria-label")).toBe(expectedAriaLabel);
9851020 });
9861021 } finally {
9871022 await mounted.cleanup();
......@@ -1050,6 +1085,38 @@ describe("ProviderModelPicker", () => {
10501085 }
10511086 });
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
10531120 it("dispatches callback with correct provider and model when selected", async () => {
10541121 const mounted = await mountPicker({
10551122 activeInstanceId: CLAUDE_INSTANCE_ID,
......@@ -1079,6 +1146,29 @@ describe("ProviderModelPicker", () => {
10791146 }
10801147 });
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
10821172 it("only shows codex spark when the server reports it", async () => {
10831173 const providersWithoutSpark: ReadonlyArray<ServerProvider> = [
10841174 buildCodexProvider([
apps/web/src/components/chat/ProviderModelPicker.tsx+66-34
......@@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4141 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
4242 triggerClassName?: string;
4343 onOpenChange?: (open: boolean) => void;
44 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
4445 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
4546}) {
4647 const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
......@@ -65,7 +66,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6566 selectedInstanceOptions.find((option) => option.slug === props.model) ??
6667 selectedInstanceOptions[0];
6768 const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model;
68 const triggerSubtitle = selectedModel?.subProvider;
6969 const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model;
7070 const duplicateDriverCount = props.instanceEntries.filter(
7171 (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind,
......@@ -86,6 +86,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
8686 };
8787 }, [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
89137 const handleInstanceModelChange = (instanceId: ProviderInstanceId, model: string) => {
90138 if (props.disabled) return;
91139 props.onInstanceModelChange(instanceId, model);
......@@ -110,7 +158,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
110158 variant={props.triggerVariant ?? "ghost"}
111159 data-chat-provider-model-picker="true"
112160 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",
114162 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
115163 props.triggerClassName,
116164 )}
......@@ -118,12 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
118166 />
119167 }
120168 >
121 <span
122 className={cn(
123 "flex min-w-0 w-full box-border items-center gap-2 overflow-hidden",
124 props.compact ? "max-w-36 sm:pl-1" : undefined,
125 )}
126 >
169 <span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
127170 {activeEntry ? (
128171 <ProviderInstanceIcon
129172 driverKind={activeEntry.driverKind}
......@@ -132,42 +175,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
132175 showBadge={showInstanceBadge}
133176 className={showInstanceBadge ? "size-5" : "size-4"}
134177 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 )}
136183 />
137184 ) : null}
138185 <Tooltip>
139 <TooltipTrigger
140 render={
141 <span
142 className={cn(
143 "min-w-0 flex-1 overflow-hidden",
144 triggerSubtitle
145 ? "grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1"
146 : "truncate",
147 )}
148 />
149 }
150 >
151 {triggerSubtitle ? (
152 <>
153 <span className="min-w-0 truncate">{triggerSubtitle}</span>
154 <span aria-hidden="true" className="shrink-0 opacity-60">
155 ·
156 </span>
157 <span className="min-w-0 truncate">{triggerTitle}</span>
158 </>
159 ) : (
160 triggerTitle
161 )}
186 <TooltipTrigger render={<span className="min-w-0 flex-1 overflow-hidden truncate" />}>
187 {triggerTitle}
162188 </TooltipTrigger>
163189 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
164190 </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" />
166194 </span>
167195 </PopoverTrigger>
168196 <PopoverPopup
169197 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"
171200 >
172201 <ModelPickerContent
173202 activeInstanceId={activeInstanceId}
......@@ -179,6 +208,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
179208 modelOptionsByInstance={props.modelOptionsByInstance}
180209 terminalOpen={props.terminalOpen ?? false}
181210 onRequestClose={() => setIsMenuOpen(false)}
211 {...(props.getModelDisabledReason
212 ? { getModelDisabledReason: props.getModelDisabledReason }
213 : {})}
182214 onInstanceModelChange={handleInstanceModelChange}
183215 />
184216 </PopoverPopup>
apps/web/src/components/chat/providerIconUtils.ts+17-6
......@@ -28,14 +28,26 @@ export type ModelEsque = {
2828 subProvider?: string | undefined;
2929};
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
3145export function getDisplayModelName(
3246 model: ModelEsque,
3347 options?: { preferShortName?: boolean },
3448): string {
35 if (options?.preferShortName && model.shortName) {
36 return model.shortName;
37 }
38 return model.name;
49 const name = options?.preferShortName && model.shortName ? model.shortName : model.name;
50 return stripLeadingQualifier(name, model.subProvider);
3951}
4052
4153export function getTriggerDisplayModelName(model: ModelEsque): string {
......@@ -43,6 +55,5 @@ export function getTriggerDisplayModelName(model: ModelEsque): string {
4355}
4456
4557export function getTriggerDisplayModelLabel(model: ModelEsque): string {
46 const title = getTriggerDisplayModelName(model);
47 return model.subProvider ? `${model.subProvider} · ${title}` : title;
58 return getTriggerDisplayModelName(model);
4859}
apps/web/src/components/ui/combobox.tsx+3
......@@ -60,6 +60,7 @@ function ComboboxInput({
6060 showClear = false,
6161 startAddon,
6262 size,
63 unstyled = false,
6364 ...props
6465}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
6566 inputClassName?: string;
......@@ -67,6 +68,7 @@ function ComboboxInput({
6768 showClear?: boolean;
6869 startAddon?: React.ReactNode;
6970 size?: "sm" | "default" | "lg" | number;
71 unstyled?: boolean;
7072 ref?: React.Ref<HTMLInputElement>;
7173}) {
7274 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
......@@ -97,6 +99,7 @@ function ComboboxInput({
9799 className={cn("has-disabled:opacity-100", inputClassName)}
98100 nativeInput
99101 size={sizeValue}
102 unstyled={unstyled}
100103 />
101104 }
102105 {...props}
apps/web/src/components/ui/popover.tsx+3
......@@ -19,6 +19,7 @@ function PopoverTrigger({ className, children, ...props }: PopoverPrimitive.Trig
1919function PopoverPopup({
2020 children,
2121 className,
22 viewportClassName,
2223 side = "bottom",
2324 align = "center",
2425 sideOffset = 4,
......@@ -27,6 +28,7 @@ function PopoverPopup({
2728 anchor,
2829 ...props
2930}: PopoverPrimitive.Popup.Props & {
31 viewportClassName?: string;
3032 side?: PopoverPrimitive.Positioner.Props["side"];
3133 align?: PopoverPrimitive.Positioner.Props["align"];
3234 sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
......@@ -61,6 +63,7 @@ function PopoverPopup({
6163 tooltipStyle
6264 ? "py-1 [--viewport-inline-padding:--spacing(2)]"
6365 : "not-data-transitioning:overflow-y-auto",
66 viewportClassName,
6467 )}
6568 data-slot="popover-viewport"
6669 >