Unified · Split
| @@ -105,6 +105,7 @@ import { · 104 unmodified lines | ||
| 105 | 105 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 106 | 106 | import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; |
| 107 | 107 | import { |
| 108 | + applyProviderInstanceSettings, | |
| 108 | 109 | deriveProviderInstanceEntries, |
| 109 | 110 | resolveProviderDriverKindForInstanceSelection, |
| 110 | 111 | sortProviderInstanceEntries, |
| @@ -662,8 +663,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 551 unmodified lines | ||
| 662 | 663 | // configured instance (default built-in + any custom `providerInstances.*`), |
| 663 | 664 | // sorted default-first per driver kind for a stable picker order. |
| 664 | 665 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| 665 | − () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), | |
| 666 | − [providerStatuses], | |
| 666 | + () => | |
| 667 | + sortProviderInstanceEntries( | |
| 668 | + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), | |
| 669 | + ), | |
| 670 | + [providerStatuses, settings], | |
| 667 | 671 | ); |
| 668 | 672 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 669 | 673 | const threadProvider = |
| @@ -22,7 +22,11 @@ import { · 21 unmodified lines | ||
| 22 | 22 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 23 | 23 | import { cn } from "~/lib/utils"; |
| 24 | 24 | import { TooltipProvider } from "../ui/tooltip"; |
| 25 | −import type { ProviderInstanceEntry } from "../../providerInstances"; | |
| 25 | +import { | |
| 26 | + isProviderInstancePickerReady, | |
| 27 | + isProviderInstancePickerVisible, | |
| 28 | + type ProviderInstanceEntry, | |
| 29 | +} from "../../providerInstances"; | |
| 26 | 30 | import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; |
| 27 | 31 | |
| 28 | 32 | type ModelPickerItem = { |
| @@ -174,7 +178,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 145 unmodified lines | ||
| 174 | 178 | const readyInstanceSet = useMemo(() => { |
| 175 | 179 | const ready = new Set<ProviderInstanceId>(); |
| 176 | 180 | for (const entry of instanceEntries) { |
| 177 | − if (entry.status === "ready") { | |
| 181 | + if (isProviderInstancePickerReady(entry)) { | |
| 178 | 182 | ready.add(entry.instanceId); |
| 179 | 183 | } |
| 180 | 184 | } |
| @@ -231,12 +235,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 50 unmodified lines | ||
| 231 | 235 | return disabled; |
| 232 | 236 | }, [instanceEntries, isLocked, matchesLockedProvider]); |
| 233 | 237 | const sidebarInstanceEntries = useMemo(() => { |
| 238 | + const enabledEntries = instanceEntries.filter(isProviderInstancePickerVisible); | |
| 234 | 239 | if (!isLocked) { |
| 235 | − return instanceEntries; | |
| 240 | + return enabledEntries; | |
| 236 | 241 | } |
| 237 | 242 | const available: ProviderInstanceEntry[] = []; |
| 238 | 243 | const disabled: ProviderInstanceEntry[] = []; |
| 239 | − for (const entry of instanceEntries) { | |
| 244 | + for (const entry of enabledEntries) { | |
| 240 | 245 | if (matchesLockedProvider(entry)) { |
| 241 | 246 | available.push(entry); |
| 242 | 247 | } else { |
| @@ -526,7 +531,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 283 unmodified lines | ||
| 526 | 531 | onSelectInstance={handleSelectInstance} |
| 527 | 532 | instanceEntries={sidebarInstanceEntries} |
| 528 | 533 | showFavorites |
| 529 | − showComingSoon | |
| 530 | 534 | {...(lockedDisabledInstanceIds |
| 531 | 535 | ? { |
| 532 | 536 | disabledInstanceIds: lockedDisabledInstanceIds, |
| @@ -1,11 +1,10 @@ | ||
| 1 | 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | 2 | import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 3 | −import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; | |
| 4 | −import { Gemini, GithubCopilotIcon } from "../Icons"; | |
| 3 | +import { SparklesIcon, StarIcon } from "lucide-react"; | |
| 5 | 4 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | 6 | import { cn } from "~/lib/utils"; |
| 8 | −import type { ProviderInstanceEntry } from "../../providerInstances"; | |
| 7 | +import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; | |
| 9 | 8 | |
| 10 | 9 | /** |
| 11 | 10 | * Build the hover tooltip for an instance button. Mirrors the old |
| @@ -14,17 +13,14 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; · 2 unmodified lines | ||
| 14 | 13 | */ |
| 15 | 14 | function describeUnavailableInstance(entry: ProviderInstanceEntry): string { |
| 16 | 15 | const label = entry.displayName; |
| 17 | − if (entry.status === "ready") { | |
| 16 | + if (!entry.enabled || entry.status === "disabled") { | |
| 17 | + return `${label} — Disabled in settings.`; | |
| 18 | + } | |
| 19 | + if (entry.status === "ready" && entry.isAvailable) { | |
| 18 | 20 | return label; |
| 19 | 21 | } |
| 20 | 22 | const kind = |
| 21 | − entry.status === "error" | |
| 22 | − ? "Unavailable" | |
| 23 | − : entry.status === "warning" | |
| 24 | − ? "Limited" | |
| 25 | − : entry.status === "disabled" | |
| 26 | − ? "Disabled in settings" | |
| 27 | − : "Not ready"; | |
| 23 | + entry.status === "error" ? "Unavailable" : entry.status === "warning" ? "Limited" : "Not ready"; | |
| 28 | 24 | const msg = entry.snapshot.message?.trim(); |
| 29 | 25 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; |
| 30 | 26 | } |
| @@ -34,7 +30,6 @@ const SELECTED_INDICATOR_CLASS = · 3 unmodified lines | ||
| 34 | 30 | const BADGE_BASE_CLASS = |
| 35 | 31 | "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 "; |
| 36 | 32 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; |
| 37 | −const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; | |
| 38 | 33 | |
| 39 | 34 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 40 | 35 | const PICKER_TOOLTIP_SIDE = "left" as const; |
| @@ -53,8 +48,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 12 unmodified lines | ||
| 53 | 48 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; |
| 54 | 49 | /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ |
| 55 | 50 | showFavorites?: boolean; |
| 56 | − /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ | |
| 57 | − showComingSoon?: boolean; | |
| 58 | 51 | /** Instance ids shown in the rail but unavailable for the current picker context. */ |
| 59 | 52 | disabledInstanceIds?: ReadonlySet<ProviderInstanceId>; |
| 60 | 53 | getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; |
| @@ -69,7 +62,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 8 unmodified lines | ||
| 69 | 62 | props.onSelectInstance(instanceId); |
| 70 | 63 | }; |
| 71 | 64 | const showFavorites = props.showFavorites ?? true; |
| 72 | − const showComingSoon = props.showComingSoon ?? true; | |
| 73 | 65 | const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null); |
| 74 | 66 | const sidebarContentRef = useRef<HTMLDivElement>(null); |
| 75 | 67 | const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null); |
| @@ -159,7 +151,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 83 unmodified lines | ||
| 159 | 151 | |
| 160 | 152 | {/* Instance buttons (one per configured instance — built-in + custom) */} |
| 161 | 153 | {props.instanceEntries.map((entry) => { |
| 162 | − const isUnavailable = !entry.isAvailable || entry.status !== "ready"; | |
| 154 | + const isUnavailable = !isProviderInstancePickerReady(entry); | |
| 163 | 155 | const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; |
| 164 | 156 | const isDisabled = isUnavailable || isContextDisabled; |
| 165 | 157 | const isSelected = props.selectedInstanceId === entry.instanceId; |
| @@ -251,76 +243,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 85 unmodified lines | ||
| 251 | 243 | </div> |
| 252 | 244 | ); |
| 253 | 245 | })} |
| 254 | − | |
| 255 | − {showComingSoon ? ( | |
| 256 | − <> | |
| 257 | − {/* Gemini button (coming soon) */} | |
| 258 | − <Tooltip> | |
| 259 | − <TooltipTrigger | |
| 260 | − render={ | |
| 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> | |
| 277 | − } | |
| 278 | − /> | |
| 279 | − <TooltipPopup | |
| 280 | − side={PICKER_TOOLTIP_SIDE} | |
| 281 | − sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 282 | − align="center" | |
| 283 | − className={PICKER_TOOLTIP_CLASS} | |
| 284 | − > | |
| 285 | − Gemini — Coming soon | |
| 286 | − </TooltipPopup> | |
| 287 | − </Tooltip> | |
| 288 | − {/* Github Copilot button (coming soon) */} | |
| 289 | − <Tooltip> | |
| 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 | − /> | |
| 313 | − <TooltipPopup | |
| 314 | − side={PICKER_TOOLTIP_SIDE} | |
| 315 | − sideOffset={PICKER_TOOLTIP_SIDE_OFFSET} | |
| 316 | − align="center" | |
| 317 | − className={PICKER_TOOLTIP_CLASS} | |
| 318 | − > | |
| 319 | − Github Copilot — Coming soon | |
| 320 | − </TooltipPopup> | |
| 321 | − </Tooltip> | |
| 322 | − </> | |
| 323 | − ) : null} | |
| 324 | 246 | </div> |
| 325 | 247 | </div> |
| 326 | 248 | </div> |
| @@ -44,6 +44,7 @@ import { · 43 unmodified lines | ||
| 44 | 44 | resolveAppModelSelectionState, |
| 45 | 45 | } from "../../modelSelection"; |
| 46 | 46 | import { |
| 47 | + applyProviderInstanceSettings, | |
| 47 | 48 | deriveProviderInstanceEntries, |
| 48 | 49 | sortProviderInstanceEntries, |
| 49 | 50 | } from "../../providerInstances"; |
| @@ -495,7 +496,7 @@ export function GeneralSettingsPanel() { · 445 unmodified lines | ||
| 495 | 496 | const textGenModel = textGenerationModelSelection.model; |
| 496 | 497 | const textGenModelOptions = textGenerationModelSelection.options; |
| 497 | 498 | const gitModelInstanceEntries = sortProviderInstanceEntries( |
| 498 | − deriveProviderInstanceEntries(serverProviders), | |
| 499 | + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), | |
| 499 | 500 | ); |
| 500 | 501 | const textGenInstanceEntry = gitModelInstanceEntries.find( |
| 501 | 502 | (entry) => entry.instanceId === textGenInstanceId, |
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | + applyProviderInstanceSettings, | |
| 4 | 5 | deriveProviderInstanceEntries, |
| 6 | + isProviderInstancePickerReady, | |
| 7 | + isProviderInstancePickerVisible, | |
| 5 | 8 | resolveSelectableProviderInstance, |
| 6 | 9 | resolveProviderDriverKindForInstanceSelection, |
| 7 | 10 | } from "./providerInstances"; |
| @@ -30,6 +33,79 @@ function provider(input: { · 22 unmodified lines | ||
| 30 | 33 | }; |
| 31 | 34 | } |
| 32 | 35 | |
| 36 | +describe("isProviderInstancePickerReady", () => { | |
| 37 | + it("rejects a disabled instance even while its last probe status is ready", () => { | |
| 38 | + const [entry] = deriveProviderInstanceEntries([ | |
| 39 | + provider({ | |
| 40 | + provider: ProviderDriverKind.make("codex"), | |
| 41 | + instanceId: "codex", | |
| 42 | + enabled: false, | |
| 43 | + }), | |
| 44 | + ]); | |
| 45 | + | |
| 46 | + expect(entry?.status).toBe("ready"); | |
| 47 | + expect(entry && isProviderInstancePickerReady(entry)).toBe(false); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("accepts an enabled, available, ready instance", () => { | |
| 51 | + const [entry] = deriveProviderInstanceEntries([ | |
| 52 | + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | |
| 53 | + ]); | |
| 54 | + | |
| 55 | + expect(entry && isProviderInstancePickerReady(entry)).toBe(true); | |
| 56 | + }); | |
| 57 | +}); | |
| 58 | + | |
| 59 | +describe("isProviderInstancePickerVisible", () => { | |
| 60 | + it("keeps enabled instances in the rail and removes disabled instances", () => { | |
| 61 | + const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([ | |
| 62 | + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | |
| 63 | + provider({ | |
| 64 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 65 | + instanceId: "claudeAgent", | |
| 66 | + enabled: false, | |
| 67 | + }), | |
| 68 | + ]); | |
| 69 | + | |
| 70 | + expect(enabledEntry && isProviderInstancePickerVisible(enabledEntry)).toBe(true); | |
| 71 | + expect(disabledEntry && isProviderInstancePickerVisible(disabledEntry)).toBe(false); | |
| 72 | + }); | |
| 73 | +}); | |
| 74 | + | |
| 75 | +describe("applyProviderInstanceSettings", () => { | |
| 76 | + it("uses settings when a streamed snapshot still reports a disabled default as enabled", () => { | |
| 77 | + const entries = deriveProviderInstanceEntries([ | |
| 78 | + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), | |
| 79 | + ]); | |
| 80 | + const [entry] = applyProviderInstanceSettings(entries, { | |
| 81 | + providerInstances: { | |
| 82 | + [ProviderInstanceId.make("codex")]: { | |
| 83 | + driver: ProviderDriverKind.make("codex"), | |
| 84 | + enabled: false, | |
| 85 | + }, | |
| 86 | + }, | |
| 87 | + providers: {} as never, | |
| 88 | + }); | |
| 89 | + | |
| 90 | + expect(entry?.enabled).toBe(false); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("treats a removed custom instance snapshot as disabled", () => { | |
| 94 | + const entries = deriveProviderInstanceEntries([ | |
| 95 | + provider({ | |
| 96 | + provider: ProviderDriverKind.make("claudeAgent"), | |
| 97 | + instanceId: "claude_work", | |
| 98 | + }), | |
| 99 | + ]); | |
| 100 | + const [entry] = applyProviderInstanceSettings(entries, { | |
| 101 | + providerInstances: {}, | |
| 102 | + providers: {} as never, | |
| 103 | + }); | |
| 104 | + | |
| 105 | + expect(entry?.enabled).toBe(false); | |
| 106 | + }); | |
| 107 | +}); | |
| 108 | + | |
| 33 | 109 | describe("deriveProviderInstanceEntries", () => { |
| 34 | 110 | it("uses explicit instance id and driver kind from the snapshot", () => { |
| 35 | 111 | const snapshot = provider({ |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | type ProviderInstanceId, |
| 20 | 20 | type ServerProvider, |
| 21 | 21 | type ServerProviderModel, |
| 22 | + type ServerSettings, | |
| 22 | 23 | type ServerProviderState, |
| 23 | 24 | } from "@t3tools/contracts"; |
| 24 | 25 | |
| @@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { · 26 unmodified lines | ||
| 51 | 52 | readonly models: ReadonlyArray<ServerProviderModel>; |
| 52 | 53 | } |
| 53 | 54 | |
| 55 | +/** | |
| 56 | + * Whether an instance can currently contribute models to an interactive picker. | |
| 57 | + * | |
| 58 | + * Disabling an instance updates `enabled` independently, while its previous | |
| 59 | + * `ready` probe status can remain in the streamed snapshot until reconciliation. | |
| 60 | + */ | |
| 61 | +export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { | |
| 62 | + return entry.enabled && entry.isAvailable && entry.status === "ready"; | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** Picker rails contain configured, enabled instances only. */ | |
| 66 | +export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { | |
| 67 | + return entry.enabled; | |
| 68 | +} | |
| 69 | + | |
| 54 | 70 | /** |
| 55 | 71 | * Turn an instance id slug into a human-readable label. Splits on `_` / `-` |
| 56 | 72 | * and camelCase boundaries and title-cases each token, so `codex_personal` |
| @@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( · 97 unmodified lines | ||
| 154 | 170 | }); |
| 155 | 171 | } |
| 156 | 172 | |
| 173 | +/** | |
| 174 | + * Overlay the current settings configuration onto streamed provider snapshots. | |
| 175 | + * Provider probes can briefly retain their previous `enabled` value after a | |
| 176 | + * settings write, so picker visibility must follow settings rather than waiting | |
| 177 | + * for probe reconciliation. | |
| 178 | + * | |
| 179 | + * Non-default instances only exist through `providerInstances`; if one is | |
| 180 | + * absent there, its streamed snapshot is stale (for example immediately after | |
| 181 | + * deletion) and is treated as disabled. | |
| 182 | + */ | |
| 183 | +export function applyProviderInstanceSettings( | |
| 184 | + entries: ReadonlyArray<ProviderInstanceEntry>, | |
| 185 | + settings: Pick<ServerSettings, "providerInstances" | "providers">, | |
| 186 | +): ReadonlyArray<ProviderInstanceEntry> { | |
| 187 | + const legacyProviders = settings.providers as Readonly< | |
| 188 | + Record<string, { readonly enabled?: boolean } | undefined> | |
| 189 | + >; | |
| 190 | + | |
| 191 | + return entries.map((entry) => { | |
| 192 | + const explicitInstance = settings.providerInstances?.[entry.instanceId]; | |
| 193 | + const enabled = explicitInstance | |
| 194 | + ? (explicitInstance.enabled ?? true) | |
| 195 | + : entry.isDefault | |
| 196 | + ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) | |
| 197 | + : false; | |
| 198 | + return enabled === entry.enabled ? entry : { ...entry, enabled }; | |
| 199 | + }); | |
| 200 | +} | |
| 201 | + | |
| 157 | 202 | /** |
| 158 | 203 | * Sort instance entries so the default instance of each driver kind appears |
| 159 | 204 | * before any custom instances of the same kind. Within a kind, custom |