1import { Toolbar } from "@base-ui/react/toolbar";
2import { type ProviderInstanceId } from "@t3tools/contracts";
3import { memo, useLayoutEffect, useRef, useState } from "react";
4import { SparklesIcon, StarIcon } from "lucide-react";
5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { cn } from "~/lib/utils";
8import {
9 isProviderInstancePickerReady,
10 shouldShowInstanceBadge,
11 type ProviderInstanceEntry,
12} from "../../providerInstances";
13
14/**
15 * Build the hover tooltip for an instance button. Mirrors the old
16 * kind-based copy but uses the entry's configured `displayName` so custom
17 * instances get their user-authored name (e.g. "Codex Personal — Unavailable.").
18 */
19function describeUnavailableInstance(entry: ProviderInstanceEntry): string {
20 const label = entry.displayName;
21 if (!entry.enabled || entry.status === "disabled") {
22 return `${label} — Disabled in settings.`;
23 }
24 if (entry.status === "ready" && entry.isAvailable) {
25 return label;
26 }
27 const kind =
28 entry.status === "error" ? "Unavailable" : entry.status === "warning" ? "Limited" : "Not ready";
29 const msg = entry.snapshot.message?.trim();
30 return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`;
31}
32
33const SELECTED_INDICATOR_CLASS =
34 "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";
35const BADGE_BASE_CLASS =
36 "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 ";
37const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update-foreground `;
38
39/** Opens toward the rail so the list stays readable (not over the model names). */
40const PICKER_TOOLTIP_SIDE = "left" as const;
41const PICKER_TOOLTIP_SIDE_OFFSET = 8;
42
43export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
44 selectedInstanceId: ProviderInstanceId | "favorites";
45 onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;
46 onFocusSearch: () => void;
47 /**
48 * Instance entries to render as rail buttons. Each entry becomes one icon
49 * keyed by `instanceId`, so the default built-in Codex and a user-authored
50 * `codex_personal` appear as two distinct rail items, each routing to
51 * their own model list.
52 */
53 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
54 /** Render the favorites rail entry. Hidden for locked-provider instance switching. */
55 showFavorites?: boolean;
56 /** Instance ids shown in the rail but unavailable for the current picker context. */
57 disabledInstanceIds?: ReadonlySet<ProviderInstanceId>;
58 /** Non-ready instances whose selected unavailable model remains reachable. */
59 selectableUnavailableInstanceIds?: ReadonlySet<ProviderInstanceId>;
60 getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string;
61 /**
62 * Instance id values that should render the "new" sparkle badge. Callers
63 * pass the subset of default built-in ids they want flagged (custom
64 * instances are never flagged — the user just made them).
65 */
66 newBadgeInstanceIds?: ReadonlySet<ProviderInstanceId>;
67}) {
68 const handleSelect = (instanceId: ProviderInstanceId | "favorites") => {
69 props.onSelectInstance(instanceId);
70 };
71 const showFavorites = props.showFavorites ?? true;
72 const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null);
73 const sidebarContentRef = useRef<HTMLDivElement>(null);
74 const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null);
75 useLayoutEffect(() => {
76 const content = sidebarContentRef.current;
77 if (!content) {
78 return;
79 }
80 const selectedItem = Array.from(
81 content.querySelectorAll<HTMLElement>("[data-model-picker-provider]"),
82 ).find((item) => item.dataset.modelPickerProvider === props.selectedInstanceId);
83 if (!selectedItem) {
84 setSelectedIndicatorTop(null);
85 return;
86 }
87 setSelectedIndicatorTop(selectedItem.offsetTop + selectedItem.offsetHeight / 2 - 10);
88 }, [props.instanceEntries, props.selectedInstanceId, showFavorites]);
89
90 return (
91 <Toolbar.Root
92 className="w-11 shrink-0 overflow-hidden bg-muted/30"
93 data-model-picker-sidebar="true"
94 aria-label="Providers"
95 orientation="vertical"
96 onKeyDown={(event) => {
97 if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
98 if (event.key === "ArrowRight") {
99 event.preventDefault();
100 props.onFocusSearch();
101 return;
102 }
103 }}
104 >
105 <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
106 <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1">
107 {selectedIndicatorTop !== null ? (
108 <div
109 data-model-picker-selected-indicator="true"
110 className={cn(
111 SELECTED_INDICATOR_CLASS,
112 "right-0 translate-y-0 transition-[top] duration-200 ease-out",
113 )}
114 style={{ top: selectedIndicatorTop }}
115 />
116 ) : null}
117 {/* Favorites section */}
118 {showFavorites ? (
119 <>
120 <div className="relative w-full" data-model-picker-provider="favorites">
121 <Tooltip>
122 <TooltipTrigger
123 render={
124 <Toolbar.Button
125 className={cn(
126 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-foreground/10 focus-visible:bg-foreground/10 focus-visible:outline-none",
127 )}
128 onClick={() => handleSelect("favorites")}
129 type="button"
130 aria-label="Favorites"
131 aria-pressed={props.selectedInstanceId === "favorites"}
132 >
133 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />
134 </Toolbar.Button>
135 }
136 />
137 <TooltipPopup
138 side={PICKER_TOOLTIP_SIDE}
139 sideOffset={PICKER_TOOLTIP_SIDE_OFFSET}
140 align="center"
141 >
142 Favorites
143 </TooltipPopup>
144 </Tooltip>
145 </div>
146 <div className="border-b border-border/70" aria-hidden="true" />
147 </>
148 ) : null}
149
150 {/* Instance buttons (one per configured instance — built-in + custom) */}
151 {props.instanceEntries.map((entry) => {
152 const isUnavailable = !isProviderInstancePickerReady(entry);
153 const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false;
154 const unavailableSelectionIsReachable =
155 props.selectableUnavailableInstanceIds?.has(entry.instanceId) ?? false;
156 const isDisabled =
157 (isUnavailable && !unavailableSelectionIsReachable) || isContextDisabled;
158 const isSelected = props.selectedInstanceId === entry.instanceId;
159 const isHovered = hoveredInstanceId === entry.instanceId;
160 const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false;
161 const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries);
162
163 const tooltip = isUnavailable
164 ? describeUnavailableInstance(entry)
165 : isContextDisabled
166 ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName)
167 : showNewBadge
168 ? `${entry.displayName} — New`
169 : entry.displayName;
170
171 const button = (
172 <Toolbar.Button
173 className={cn(
174 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-foreground/10 focus-visible:bg-foreground/10 focus-visible:outline-none",
175 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
176 )}
177 data-provider-accent-color={entry.accentColor}
178 onClick={() => !isDisabled && handleSelect(entry.instanceId)}
179 onMouseEnter={() => setHoveredInstanceId(entry.instanceId)}
180 onMouseLeave={() =>
181 setHoveredInstanceId((current) => (current === entry.instanceId ? null : current))
182 }
183 onFocus={() => setHoveredInstanceId(entry.instanceId)}
184 onBlur={() =>
185 setHoveredInstanceId((current) => (current === entry.instanceId ? null : current))
186 }
187 disabled={isDisabled}
188 focusableWhenDisabled={!isDisabled}
189 aria-pressed={isSelected}
190 type="button"
191 aria-label={
192 isUnavailable || isContextDisabled
193 ? tooltip
194 : showNewBadge
195 ? `${entry.displayName}, new`
196 : entry.displayName
197 }
198 >
199 <ProviderInstanceIcon
200 driverKind={entry.driverKind}
201 displayName={entry.displayName}
202 accentColor={entry.accentColor}
203 acpRegistryAgentId={entry.acpRegistryAgentId}
204 acpRegistryIconUrl={entry.acpRegistryIconUrl}
205 showBadge={showInstanceBadge}
206 className="size-6 z-30"
207 iconClassName="size-5"
208 indicatorBackground={
209 isHovered && !isDisabled
210 ? "var(--muted)"
211 : isSelected
212 ? "var(--background)"
213 : "color-mix(in oklab, var(--muted) 30%, transparent)"
214 }
215 {...(entry.accentColor ? { badgeClassName: "h-3 min-w-3 px-0.5 text-5xs" } : {})}
216 />
217 {showNewBadge ? (
218 <span className={NEW_BADGE_CLASS} aria-hidden>
219 <SparklesIcon className="size-2" />
220 </span>
221 ) : null}
222 </Toolbar.Button>
223 );
224
225 const trigger = isDisabled ? (
226 <span className="relative block w-full">{button}</span>
227 ) : (
228 button
229 );
230
231 return (
232 <div
233 key={entry.instanceId}
234 className="relative w-full"
235 data-model-picker-provider={entry.instanceId}
236 >
237 <Tooltip>
238 <TooltipTrigger render={trigger} />
239 <TooltipPopup
240 side={PICKER_TOOLTIP_SIDE}
241 sideOffset={PICKER_TOOLTIP_SIDE_OFFSET}
242 align="center"
243 >
244 {tooltip}
245 </TooltipPopup>
246 </Tooltip>
247 </div>
248 );
249 })}
250 </div>
251 </div>
252 </Toolbar.Root>
253 );
254});