| 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 | 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) |
| 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 = |
apps/web/src/components/chat/ModelPickerContent.tsx+9-5| ... | ... | @@ -22,7 +22,11 @@ import { |
| 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: { |
| 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: { |
| 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: { |
| 526 | 531 | onSelectInstance={handleSelectInstance} |
| 527 | 532 | instanceEntries={sidebarInstanceEntries} |
| 528 | 533 | showFavorites |
| 529 | showComingSoon | |
| 530 | 534 | {...(lockedDisabledInstanceIds |
| 531 | 535 | ? { |
| 532 | 536 | disabledInstanceIds: lockedDisabledInstanceIds, |
apps/web/src/components/chat/ModelPickerSidebar.tsx+8-86| ... | ... | @@ -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"; |
| 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 = |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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> |
apps/web/src/components/settings/SettingsPanels.tsx+2-1| ... | ... | @@ -44,6 +44,7 @@ import { |
| 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() { |
| 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, |
apps/web/src/providerInstances.test.ts+76| ... | ... | @@ -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: { |
| 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({ |
apps/web/src/providerInstances.ts+45| ... | ... | @@ -19,6 +19,7 @@ import { |
| 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 { |
| 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( |
| 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 |