| author | |
| committer | |
| log | fbf6263876fd9b6f8bcbbf6b9ea097ed8fc7cb60 |
| tree | 94830ba2b514f12e295e46d0250b41ad014c8e3e |
| parent | c08b968f4e8078027b35752a1bc980cb03ac8fd8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@mac.lan>6 files changed, 146 insertions(+), 94 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+6-2| ... | @@ -105,6 +105,7 @@ import { | ... | @@ -105,6 +105,7 @@ import { |
| 105 | import { proposedPlanTitle } from "../../proposedPlan"; | 105 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 106 | import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; | 106 | import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; |
| 107 | import { | 107 | import { |
| 108 | applyProviderInstanceSettings, | ||
| 108 | deriveProviderInstanceEntries, | 109 | deriveProviderInstanceEntries, |
| 109 | resolveProviderDriverKindForInstanceSelection, | 110 | resolveProviderDriverKindForInstanceSelection, |
| 110 | sortProviderInstanceEntries, | 111 | sortProviderInstanceEntries, |
| ... | @@ -662,8 +663,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -662,8 +663,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 662 | // configured instance (default built-in + any custom `providerInstances.*`), | 663 | // configured instance (default built-in + any custom `providerInstances.*`), |
| 663 | // sorted default-first per driver kind for a stable picker order. | 664 | // sorted default-first per driver kind for a stable picker order. |
| 664 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( | 665 | const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>( |
| 665 | () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), | 666 | () => |
| 666 | [providerStatuses], | 667 | sortProviderInstanceEntries( |
| 668 | applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), | ||
| 669 | ), | ||
| 670 | [providerStatuses, settings], | ||
| 667 | ); | 671 | ); |
| 668 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; | 672 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 669 | const threadProvider = | 673 | const threadProvider = |
apps/web/src/components/chat/ModelPickerContent.tsx+9-5| ... | @@ -22,7 +22,11 @@ import { | ... | @@ -22,7 +22,11 @@ import { |
| 22 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 22 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 23 | import { cn } from "~/lib/utils"; | 23 | import { cn } from "~/lib/utils"; |
| 24 | import { TooltipProvider } from "../ui/tooltip"; | 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 | import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; | 30 | import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; |
| 27 | 31 | ||
| 28 | type ModelPickerItem = { | 32 | type ModelPickerItem = { |
| ... | @@ -174,7 +178,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -174,7 +178,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 174 | const readyInstanceSet = useMemo(() => { | 178 | const readyInstanceSet = useMemo(() => { |
| 175 | const ready = new Set<ProviderInstanceId>(); | 179 | const ready = new Set<ProviderInstanceId>(); |
| 176 | for (const entry of instanceEntries) { | 180 | for (const entry of instanceEntries) { |
| 177 | if (entry.status === "ready") { | 181 | if (isProviderInstancePickerReady(entry)) { |
| 178 | ready.add(entry.instanceId); | 182 | ready.add(entry.instanceId); |
| 179 | } | 183 | } |
| 180 | } | 184 | } |
| ... | @@ -231,12 +235,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -231,12 +235,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 231 | return disabled; | 235 | return disabled; |
| 232 | }, [instanceEntries, isLocked, matchesLockedProvider]); | 236 | }, [instanceEntries, isLocked, matchesLockedProvider]); |
| 233 | const sidebarInstanceEntries = useMemo(() => { | 237 | const sidebarInstanceEntries = useMemo(() => { |
| 238 | const enabledEntries = instanceEntries.filter(isProviderInstancePickerVisible); | ||
| 234 | if (!isLocked) { | 239 | if (!isLocked) { |
| 235 | return instanceEntries; | 240 | return enabledEntries; |
| 236 | } | 241 | } |
| 237 | const available: ProviderInstanceEntry[] = []; | 242 | const available: ProviderInstanceEntry[] = []; |
| 238 | const disabled: ProviderInstanceEntry[] = []; | 243 | const disabled: ProviderInstanceEntry[] = []; |
| 239 | for (const entry of instanceEntries) { | 244 | for (const entry of enabledEntries) { |
| 240 | if (matchesLockedProvider(entry)) { | 245 | if (matchesLockedProvider(entry)) { |
| 241 | available.push(entry); | 246 | available.push(entry); |
| 242 | } else { | 247 | } else { |
| ... | @@ -526,7 +531,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -526,7 +531,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 526 | onSelectInstance={handleSelectInstance} | 531 | onSelectInstance={handleSelectInstance} |
| 527 | instanceEntries={sidebarInstanceEntries} | 532 | instanceEntries={sidebarInstanceEntries} |
| 528 | showFavorites | 533 | showFavorites |
| 529 | showComingSoon | ||
| 530 | {...(lockedDisabledInstanceIds | 534 | {...(lockedDisabledInstanceIds |
| 531 | ? { | 535 | ? { |
| 532 | disabledInstanceIds: lockedDisabledInstanceIds, | 536 | disabledInstanceIds: lockedDisabledInstanceIds, |
apps/web/src/components/chat/ModelPickerSidebar.tsx+8-86| ... | @@ -1,11 +1,10 @@ | ... | @@ -1,11 +1,10 @@ |
| 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; | 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; | 2 | import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; | 3 | import { SparklesIcon, StarIcon } from "lucide-react"; |
| 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; | ||
| 5 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; | 4 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 5 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | import { cn } from "~/lib/utils"; | 6 | import { cn } from "~/lib/utils"; |
| 8 | import type { ProviderInstanceEntry } from "../../providerInstances"; | 7 | import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; |
| 9 | 8 | ||
| 10 | /** | 9 | /** |
| 11 | * Build the hover tooltip for an instance button. Mirrors the old | 10 | * Build the hover tooltip for an instance button. Mirrors the old |
| ... | @@ -14,17 +13,14 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; | ... | @@ -14,17 +13,14 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; |
| 14 | */ | 13 | */ |
| 15 | function describeUnavailableInstance(entry: ProviderInstanceEntry): string { | 14 | function describeUnavailableInstance(entry: ProviderInstanceEntry): string { |
| 16 | const label = entry.displayName; | 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 | return label; | 20 | return label; |
| 19 | } | 21 | } |
| 20 | const kind = | 22 | const kind = |
| 21 | entry.status === "error" | 23 | entry.status === "error" ? "Unavailable" : entry.status === "warning" ? "Limited" : "Not ready"; |
| 22 | ? "Unavailable" | ||
| 23 | : entry.status === "warning" | ||
| 24 | ? "Limited" | ||
| 25 | : entry.status === "disabled" | ||
| 26 | ? "Disabled in settings" | ||
| 27 | : "Not ready"; | ||
| 28 | const msg = entry.snapshot.message?.trim(); | 24 | const msg = entry.snapshot.message?.trim(); |
| 29 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; | 25 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; |
| 30 | } | 26 | } |
| ... | @@ -34,7 +30,6 @@ const SELECTED_INDICATOR_CLASS = | ... | @@ -34,7 +30,6 @@ const SELECTED_INDICATOR_CLASS = |
| 34 | const BADGE_BASE_CLASS = | 30 | const BADGE_BASE_CLASS = |
| 35 | "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; | 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 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; | 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 | /** Opens toward the rail so the list stays readable (not over the model names). */ | 34 | /** Opens toward the rail so the list stays readable (not over the model names). */ |
| 40 | const PICKER_TOOLTIP_SIDE = "left" as const; | 35 | const PICKER_TOOLTIP_SIDE = "left" as const; |
| ... | @@ -53,8 +48,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -53,8 +48,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 53 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; | 48 | instanceEntries: ReadonlyArray<ProviderInstanceEntry>; |
| 54 | /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ | 49 | /** Render the favorites rail entry. Hidden for locked-provider instance switching. */ |
| 55 | showFavorites?: boolean; | 50 | showFavorites?: boolean; |
| 56 | /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ | ||
| 57 | showComingSoon?: boolean; | ||
| 58 | /** Instance ids shown in the rail but unavailable for the current picker context. */ | 51 | /** Instance ids shown in the rail but unavailable for the current picker context. */ |
| 59 | disabledInstanceIds?: ReadonlySet<ProviderInstanceId>; | 52 | disabledInstanceIds?: ReadonlySet<ProviderInstanceId>; |
| 60 | getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; | 53 | getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; |
| ... | @@ -69,7 +62,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -69,7 +62,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 69 | props.onSelectInstance(instanceId); | 62 | props.onSelectInstance(instanceId); |
| 70 | }; | 63 | }; |
| 71 | const showFavorites = props.showFavorites ?? true; | 64 | const showFavorites = props.showFavorites ?? true; |
| 72 | const showComingSoon = props.showComingSoon ?? true; | ||
| 73 | const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null); | 65 | const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null); |
| 74 | const sidebarContentRef = useRef<HTMLDivElement>(null); | 66 | const sidebarContentRef = useRef<HTMLDivElement>(null); |
| 75 | const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null); | 67 | const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null); |
| ... | @@ -159,7 +151,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -159,7 +151,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 159 | 151 | ||
| 160 | {/* Instance buttons (one per configured instance — built-in + custom) */} | 152 | {/* Instance buttons (one per configured instance — built-in + custom) */} |
| 161 | {props.instanceEntries.map((entry) => { | 153 | {props.instanceEntries.map((entry) => { |
| 162 | const isUnavailable = !entry.isAvailable || entry.status !== "ready"; | 154 | const isUnavailable = !isProviderInstancePickerReady(entry); |
| 163 | const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; | 155 | const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; |
| 164 | const isDisabled = isUnavailable || isContextDisabled; | 156 | const isDisabled = isUnavailable || isContextDisabled; |
| 165 | const isSelected = props.selectedInstanceId === entry.instanceId; | 157 | const isSelected = props.selectedInstanceId === entry.instanceId; |
| ... | @@ -251,76 +243,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -251,76 +243,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 251 | </div> | 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 | </div> | 246 | </div> |
| 325 | </div> | 247 | </div> |
| 326 | </div> | 248 | </div> |
apps/web/src/components/settings/SettingsPanels.tsx+2-1| ... | @@ -44,6 +44,7 @@ import { | ... | @@ -44,6 +44,7 @@ import { |
| 44 | resolveAppModelSelectionState, | 44 | resolveAppModelSelectionState, |
| 45 | } from "../../modelSelection"; | 45 | } from "../../modelSelection"; |
| 46 | import { | 46 | import { |
| 47 | applyProviderInstanceSettings, | ||
| 47 | deriveProviderInstanceEntries, | 48 | deriveProviderInstanceEntries, |
| 48 | sortProviderInstanceEntries, | 49 | sortProviderInstanceEntries, |
| 49 | } from "../../providerInstances"; | 50 | } from "../../providerInstances"; |
| ... | @@ -495,7 +496,7 @@ export function GeneralSettingsPanel() { | ... | @@ -495,7 +496,7 @@ export function GeneralSettingsPanel() { |
| 495 | const textGenModel = textGenerationModelSelection.model; | 496 | const textGenModel = textGenerationModelSelection.model; |
| 496 | const textGenModelOptions = textGenerationModelSelection.options; | 497 | const textGenModelOptions = textGenerationModelSelection.options; |
| 497 | const gitModelInstanceEntries = sortProviderInstanceEntries( | 498 | const gitModelInstanceEntries = sortProviderInstanceEntries( |
| 498 | deriveProviderInstanceEntries(serverProviders), | 499 | applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), |
| 499 | ); | 500 | ); |
| 500 | const textGenInstanceEntry = gitModelInstanceEntries.find( | 501 | const textGenInstanceEntry = gitModelInstanceEntries.find( |
| 501 | (entry) => entry.instanceId === textGenInstanceId, | 502 | (entry) => entry.instanceId === textGenInstanceId, |
apps/web/src/providerInstances.test.ts+76| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | applyProviderInstanceSettings, | ||
| 4 | deriveProviderInstanceEntries, | 5 | deriveProviderInstanceEntries, |
| 6 | isProviderInstancePickerReady, | ||
| 7 | isProviderInstancePickerVisible, | ||
| 5 | resolveSelectableProviderInstance, | 8 | resolveSelectableProviderInstance, |
| 6 | resolveProviderDriverKindForInstanceSelection, | 9 | resolveProviderDriverKindForInstanceSelection, |
| 7 | } from "./providerInstances"; | 10 | } from "./providerInstances"; |
| ... | @@ -30,6 +33,79 @@ function provider(input: { | ... | @@ -30,6 +33,79 @@ function provider(input: { |
| 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 | describe("deriveProviderInstanceEntries", () => { | 109 | describe("deriveProviderInstanceEntries", () => { |
| 34 | it("uses explicit instance id and driver kind from the snapshot", () => { | 110 | it("uses explicit instance id and driver kind from the snapshot", () => { |
| 35 | const snapshot = provider({ | 111 | const snapshot = provider({ |
apps/web/src/providerInstances.ts+45| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | type ProviderInstanceId, | 19 | type ProviderInstanceId, |
| 20 | type ServerProvider, | 20 | type ServerProvider, |
| 21 | type ServerProviderModel, | 21 | type ServerProviderModel, |
| 22 | type ServerSettings, | ||
| 22 | type ServerProviderState, | 23 | type ServerProviderState, |
| 23 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 24 | 25 | ||
| ... | @@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { | ... | @@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { |
| 51 | readonly models: ReadonlyArray<ServerProviderModel>; | 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 | * Turn an instance id slug into a human-readable label. Splits on `_` / `-` | 71 | * Turn an instance id slug into a human-readable label. Splits on `_` / `-` |
| 56 | * and camelCase boundaries and title-cases each token, so `codex_personal` | 72 | * and camelCase boundaries and title-cases each token, so `codex_personal` |
| ... | @@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( | ... | @@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( |
| 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 | * Sort instance entries so the default instance of each driver kind appears | 203 | * Sort instance entries so the default instance of each driver kind appears |
| 159 | * before any custom instances of the same kind. Within a kind, custom | 204 | * before any custom instances of the same kind. Within a kind, custom |