| 1 | import { Toolbar } from "@base-ui/react/toolbar"; |
| 2 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 3 | import { memo, useLayoutEffect, useRef, useState } from "react"; |
| 4 | import { SparklesIcon, StarIcon } from "lucide-react"; |
| 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | import { cn } from "~/lib/utils"; |
| 8 | import { |
| 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 | */ |
| 19 | function 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 | |
| 33 | const 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"; |
| 35 | const 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 "; |
| 37 | const 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). */ |
| 40 | const PICKER_TOOLTIP_SIDE = "left" as const; |
| 41 | const PICKER_TOOLTIP_SIDE_OFFSET = 8; |
| 42 | |
| 43 | export 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 | }); |