authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 12:14:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 19:14:06+00:00
logfbf6263876fd9b6f8bcbbf6b9ea097ed8fc7cb60
tree94830ba2b514f12e295e46d0250b41ad014c8e3e
parentc08b968f4e8078027b35752a1bc980cb03ac8fd8
signature Signed by PGP key B5690EEEBB952194

Only show enabled providers in picker sidebar (#3168)

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 {
105import { proposedPlanTitle } from "../../proposedPlan";105import { proposedPlanTitle } from "../../proposedPlan";
106import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels";106import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels";
107import {107import {
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 {
22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
23import { cn } from "~/lib/utils";23import { cn } from "~/lib/utils";
24import { TooltipProvider } from "../ui/tooltip";24import { TooltipProvider } from "../ui/tooltip";
25import type { ProviderInstanceEntry } from "../../providerInstances";25import {
26 isProviderInstancePickerReady,
27 isProviderInstancePickerVisible,
28 type ProviderInstanceEntry,
29} from "../../providerInstances";
26import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";30import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
2731
28type ModelPickerItem = {32type 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 showFavorites533 showFavorites
529 showComingSoon
530 {...(lockedDisabledInstanceIds534 {...(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 @@
1import { type ProviderInstanceId } from "@t3tools/contracts";1import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";2import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";3import { SparklesIcon, StarIcon } from "lucide-react";
4import { Gemini, GithubCopilotIcon } from "../Icons";
5import { ProviderInstanceIcon } from "./ProviderInstanceIcon";4import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
8import type { ProviderInstanceEntry } from "../../providerInstances";7import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances";
98
10/**9/**
11 * Build the hover tooltip for an instance button. Mirrors the old10 * 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 */
15function describeUnavailableInstance(entry: ProviderInstanceEntry): string {14function 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 =
34const BADGE_BASE_CLASS =30const 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 ";
36const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `;32const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `;
37const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `;
3833
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). */
40const PICKER_TOOLTIP_SIDE = "left" as const;35const 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: {
159151
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";
46import {46import {
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 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3import {3import {
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}
3235
36describe("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
59describe("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
75describe("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
33describe("deriveProviderInstanceEntries", () => {109describe("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";
2425
...@@ -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}
5354
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 */
61export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean {
62 return entry.enabled && entry.isAvailable && entry.status === "ready";
63}
64
65/** Picker rails contain configured, enabled instances only. */
66export 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}
156172
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 */
183export 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 appears203 * Sort instance entries so the default instance of each driver kind appears
159 * before any custom instances of the same kind. Within a kind, custom204 * before any custom instances of the same kind. Within a kind, custom