Skip to content

Commit fbf6263

Browse files
juliusmarmingeJulius Marminge
andauthored
Only show enabled providers in picker sidebar (#3168)
Co-authored-by: Julius Marminge <julius@mac.lan>
1 parent c08b968 commit fbf6263

6 files changed

Lines changed: 146 additions & 94 deletions

File tree

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
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)
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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
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: {
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: {
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: {
526531
onSelectInstance={handleSelectInstance}
527532
instanceEntries={sidebarInstanceEntries}
528533
showFavorites
529-
showComingSoon
530534
{...(lockedDisabledInstanceIds
531535
? {
532536
disabledInstanceIds: lockedDisabledInstanceIds,

apps/web/src/components/chat/ModelPickerSidebar.tsx

Lines changed: 8 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
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 =
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: {
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: {
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: {
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: {
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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
resolveAppModelSelectionState,
4545
} from "../../modelSelection";
4646
import {
47+
applyProviderInstanceSettings,
4748
deriveProviderInstanceEntries,
4849
sortProviderInstanceEntries,
4950
} from "../../providerInstances";
@@ -495,7 +496,7 @@ export function GeneralSettingsPanel() {
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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: {
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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
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 {
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(
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

0 commit comments

Comments
 (0)