History

Only show enabled providers in picker sidebar (#3168)

Julius Marminge committed 4 months ago

Commit fbf62631 parent c08b9686 files · +146 −94Browse files

Unified · Split

6 files
  1. apps/web/src/components/chat/ChatComposer.tsx +6 −2
  2. apps/web/src/components/chat/ModelPickerContent.tsx +9 −5
  3. apps/web/src/components/chat/ModelPickerSidebar.tsx +8 −86
  4. apps/web/src/components/settings/SettingsPanels.tsx +2 −1
  5. apps/web/src/providerInstances.test.ts +76 −0
  6. apps/web/src/providerInstances.ts +45 −0
apps/web/src/components/chat/ChatComposer.tsx+6−2
@@ -105,6 +105,7 @@ import { · 104 unmodified lines
105105 import { proposedPlanTitle } from "../../proposedPlan";
106106 import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels";
107107 import {
108+ applyProviderInstanceSettings,
108109 deriveProviderInstanceEntries,
109110 resolveProviderDriverKindForInstanceSelection,
110111 sortProviderInstanceEntries,
@@ -662,8 +663,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 551 unmodified lines
662663 // configured instance (default built-in + any custom `providerInstances.*`),
663664 // sorted default-first per driver kind for a stable picker order.
664665 const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
665− () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)),
666− [providerStatuses],
666+ () =>
667+ sortProviderInstanceEntries(
668+ applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
669+ ),
670+ [providerStatuses, settings],
667671 );
668672 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
669673 const threadProvider =
apps/web/src/components/chat/ModelPickerContent.tsx+9−5
@@ -22,7 +22,11 @@ import { · 21 unmodified lines
2222 import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
2323 import { cn } from "~/lib/utils";
2424 import { TooltipProvider } from "../ui/tooltip";
25−import type { ProviderInstanceEntry } from "../../providerInstances";
25+import {
26+ isProviderInstancePickerReady,
27+ isProviderInstancePickerVisible,
28+ type ProviderInstanceEntry,
29+} from "../../providerInstances";
2630 import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
2731
2832 type ModelPickerItem = {
@@ -174,7 +178,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 145 unmodified lines
174178 const readyInstanceSet = useMemo(() => {
175179 const ready = new Set<ProviderInstanceId>();
176180 for (const entry of instanceEntries) {
177− if (entry.status === "ready") {
181+ if (isProviderInstancePickerReady(entry)) {
178182 ready.add(entry.instanceId);
179183 }
180184 }
@@ -231,12 +235,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 50 unmodified lines
231235 return disabled;
232236 }, [instanceEntries, isLocked, matchesLockedProvider]);
233237 const sidebarInstanceEntries = useMemo(() => {
238+ const enabledEntries = instanceEntries.filter(isProviderInstancePickerVisible);
234239 if (!isLocked) {
235− return instanceEntries;
240+ return enabledEntries;
236241 }
237242 const available: ProviderInstanceEntry[] = [];
238243 const disabled: ProviderInstanceEntry[] = [];
239− for (const entry of instanceEntries) {
244+ for (const entry of enabledEntries) {
240245 if (matchesLockedProvider(entry)) {
241246 available.push(entry);
242247 } else {
@@ -526,7 +531,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 283 unmodified lines
526531 onSelectInstance={handleSelectInstance}
527532 instanceEntries={sidebarInstanceEntries}
528533 showFavorites
529− showComingSoon
530534 {...(lockedDisabledInstanceIds
531535 ? {
532536 disabledInstanceIds: lockedDisabledInstanceIds,
apps/web/src/components/chat/ModelPickerSidebar.tsx+8−86
@@ -1,11 +1,10 @@
11 import { type ProviderInstanceId } from "@t3tools/contracts";
22 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";
54 import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
65 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76 import { cn } from "~/lib/utils";
8−import type { ProviderInstanceEntry } from "../../providerInstances";
7+import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances";
98
109 /**
1110 * Build the hover tooltip for an instance button. Mirrors the old
@@ -14,17 +13,14 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; · 2 unmodified lines
1413 */
1514 function describeUnavailableInstance(entry: ProviderInstanceEntry): string {
1615 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) {
1820 return label;
1921 }
2022 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";
2824 const msg = entry.snapshot.message?.trim();
2925 return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`;
3026 }
@@ -34,7 +30,6 @@ const SELECTED_INDICATOR_CLASS = · 3 unmodified lines
3430 const BADGE_BASE_CLASS =
3531 "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 ";
3632 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 `;
3833
3934 /** Opens toward the rail so the list stays readable (not over the model names). */
4035 const PICKER_TOOLTIP_SIDE = "left" as const;
@@ -53,8 +48,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 12 unmodified lines
5348 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
5449 /** Render the favorites rail entry. Hidden for locked-provider instance switching. */
5550 showFavorites?: boolean;
56− /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */
57− showComingSoon?: boolean;
5851 /** Instance ids shown in the rail but unavailable for the current picker context. */
5952 disabledInstanceIds?: ReadonlySet<ProviderInstanceId>;
6053 getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string;
@@ -69,7 +62,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 8 unmodified lines
6962 props.onSelectInstance(instanceId);
7063 };
7164 const showFavorites = props.showFavorites ?? true;
72− const showComingSoon = props.showComingSoon ?? true;
7365 const [hoveredInstanceId, setHoveredInstanceId] = useState<ProviderInstanceId | null>(null);
7466 const sidebarContentRef = useRef<HTMLDivElement>(null);
7567 const [selectedIndicatorTop, setSelectedIndicatorTop] = useState<number | null>(null);
@@ -159,7 +151,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 83 unmodified lines
159151
160152 {/* Instance buttons (one per configured instance — built-in + custom) */}
161153 {props.instanceEntries.map((entry) => {
162− const isUnavailable = !entry.isAvailable || entry.status !== "ready";
154+ const isUnavailable = !isProviderInstancePickerReady(entry);
163155 const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false;
164156 const isDisabled = isUnavailable || isContextDisabled;
165157 const isSelected = props.selectedInstanceId === entry.instanceId;
@@ -251,76 +243,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { · 85 unmodified lines
251243 </div>
252244 );
253245 })}
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}
324246 </div>
325247 </div>
326248 </div>
apps/web/src/components/settings/SettingsPanels.tsx+2−1
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
4444 resolveAppModelSelectionState,
4545 } from "../../modelSelection";
4646 import {
47+ applyProviderInstanceSettings,
4748 deriveProviderInstanceEntries,
4849 sortProviderInstanceEntries,
4950 } from "../../providerInstances";
@@ -495,7 +496,7 @@ export function GeneralSettingsPanel() { · 445 unmodified lines
495496 const textGenModel = textGenerationModelSelection.model;
496497 const textGenModelOptions = textGenerationModelSelection.options;
497498 const gitModelInstanceEntries = sortProviderInstanceEntries(
498− deriveProviderInstanceEntries(serverProviders),
499+ applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
499500 );
500501 const textGenInstanceEntry = gitModelInstanceEntries.find(
501502 (entry) => entry.instanceId === textGenInstanceId,
apps/web/src/providerInstances.test.ts+76−0
@@ -1,7 +1,10 @@
11 import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22 import { describe, expect, it } from "vite-plus/test";
33 import {
4+ applyProviderInstanceSettings,
45 deriveProviderInstanceEntries,
6+ isProviderInstancePickerReady,
7+ isProviderInstancePickerVisible,
58 resolveSelectableProviderInstance,
69 resolveProviderDriverKindForInstanceSelection,
710 } from "./providerInstances";
@@ -30,6 +33,79 @@ function provider(input: { · 22 unmodified lines
3033 };
3134 }
3235
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+
33109 describe("deriveProviderInstanceEntries", () => {
34110 it("uses explicit instance id and driver kind from the snapshot", () => {
35111 const snapshot = provider({
apps/web/src/providerInstances.ts+45−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 type ProviderInstanceId,
2020 type ServerProvider,
2121 type ServerProviderModel,
22+ type ServerSettings,
2223 type ServerProviderState,
2324 } from "@t3tools/contracts";
2425
@@ -51,6 +52,21 @@ export interface ProviderInstanceEntry { · 26 unmodified lines
5152 readonly models: ReadonlyArray<ServerProviderModel>;
5253 }
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+ */
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+
5470 /**
5571 * Turn an instance id slug into a human-readable label. Splits on `_` / `-`
5672 * and camelCase boundaries and title-cases each token, so `codex_personal`
@@ -154,6 +170,35 @@ export function deriveProviderInstanceEntries( · 97 unmodified lines
154170 });
155171 }
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+ */
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+
157202 /**
158203 * Sort instance entries so the default instance of each driver kind appears
159204 * before any custom instances of the same kind. Within a kind, custom